🙄

Reactでアコーディオンを実装するにあたってrequestAnimationFrameが使えた話

2025/02/15に公開1

月一記事書くと宣言したのにもう2月です。。。
猫を飼い始めて何もできなくなったんです。。。。。

副業先で任意の行数以上になると3点リードで表示し、Open的なボタンを押した際に全文表示させるようなUIを実装した備忘録です

実装するにあたって躓いたこと

ただのアコーディオンと言ったら変な気がしますが、いわゆるdetailsを使用したようなものでは要件を満たせませんでした
いわゆるline-clampを使用して指定した行数以上になったときは、3点リードを表示させたかったのでdetailsを使用して実装するとなると、コネクリ回せばできたかもですがコードが複雑になってしまうので好みの実装にはなりませんでした。
line-clampの参考記事

一旦実装できるまで

今回の実装するPJにはvanilla-extractが導入されていましたので、variantsでWebkitLineClampを変動する様なコードを記述しました。以下は例となるコードです。

export const exampleStyle = recipe({
  base: {
    wordBreak: "break-all",
    WebkitBoxOrient: "vertical",
  },
  variants: {
    isOpen: {
      true: {
        overflow: "visible",
        display: "block",
        WebkitLineClamp: "unset",
      },
      false: {
        overflow: "hidden",
        display: "-webkit-box",
        WebkitLineClamp: 5,
      },
    },
  },
  defaultVariants: {
    isOpen: false,
  },
});

最近はアニメーションがないとリッチに見えないよねーと思いつきたしたstyle

export const exampleStyle = recipe({
  base: {
    wordBreak: "break-all",
    WebkitBoxOrient: "vertical",
    transition: "max-height 500ms ease",
  },
  variants: {
    isOpen: {
      true: {
        overflow: "visible",
        display: "block",
        WebkitLineClamp: "unset",
        maxHeight: "100vh",
      },
      false: {
        overflow: "hidden",
        display: "-webkit-box",
        WebkitLineClamp: 5,
        maxHeight: "80px", // fontSizeが16pxのため*5で5行分としてます
      },
    },
  },
  defaultVariants: {
    isOpen: false,
  },
});

しかしこれではopenの時はアニメーションされるのですが、closeの時はアニメーションできずカク着いた感じに見え、さらに表示される部分の高さが100vh以上の時に表示がバグってしまったり、アニメーションしてますが、500msでopenできてなかったりします

いろいろ調べたこと

vanilla-extractって数値はvariantsで渡せないんですかね?情報やサンプルが見つからなかったので、もし知っている価値たら教えていただきたいです。
なので直接htmlタグにstyleを渡す形で実装したかったためrefを使用し実装してみました。
ここで基礎的な落とし穴にはまってしまったのですがrefはreactの再レンダリング実行時に再検証??されないためうまくrefの値がreactの世界の中に入れないのでMOREで全文表示しても、再レンダリングされないため、Pの高さが取得できません

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLParagraphElement>(null);
  const [contentHeight, setContentHeight] = useState(0);

  useEffect(() => {
    if (ref.current) {
      setContentHeight(ref.current.clientHeight);
    }
  }, [isOpen]);

  return (
    <div>
      <p
        ref={ref}
        className={exampleStyle({ isOpen })}
        style={{ height: isOpen ? `${contentHeight}px` : "125px" }}
      >
        HIHIHIHI....
      </p>
      <button type='button' onClick={() => setIsOpen((prev) => !prev)}>
        {isOpen ? "CLOSE" : "MORE"}
      </button>
    </div>
  );
}

export default App;

refはreactの世界から出てjsの世界の話になると思っているので、jsの世界で再度値を取得させたい時にどうすればいいか調べていた時に知ったものがrequestAnimationFrameになります

requestAnimationFrame

requestAnimationFrame

ブラウザーにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。このメソッドは、再描画の前に呼び出されるコールバック 1 個を引数として取ります。

と記載されています。例のコードではjsで直接アニメーションを操作しているのですが、僕の解釈ではjsxの世界のstyleを同じ様なことをしていると思っています。
なのでreactの世界から外れているrefをコールバックに入れると値が再検証されアニメーションできるかと思い以下の様に実装しました

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const [contentHeight, setContentHeight] = useState(0);

  useEffect(() => {
    if (ref.current) {
      const refRect = ref.current.getBoundingClientRect();
      setContentHeight(refRect.height);
    }
  }, []);

  const handleToggle = () => {
    if (!isOpen) {
      setIsOpen(true);
      window.requestAnimationFrame(() => {
        if (ref.current) {
          const refRect = ref.current.getBoundingClientRect();
          setContentHeight(refRect.height);
        }
      });
    } else {
      setIsOpen(false);
    }
  };

  return (
    <div>
      <p
        className={exampleStyle({ isOpen })}
        style={{ height: isOpen ? `${contentHeight}px` : "125px" }}
      >
        <div ref={ref}>
          HIHIHIHIHI....
        </div>
      </p>
      <button type='button' onClick={handleToggle}>
        {isOpen ? "CLOSE" : "MORE"}
      </button>
    </div>
  );
}

export default App;

いい感じにアニメーションできる様になりましたー

記事書くのムジー。。パッションで伝えすぎた代償が大人になって出ていることを実感しました

Discussion

Honey32Honey32

失礼します。

推測になりますが、

style={{ maxHeight: isOpen ? undefined : "125px" }}

とするだけで、 contentHeight ステートを使わずとも実装可能ではないかと思います。