🐧

Reactでスムーズアコーディオンを実装する

2023/01/19に公開

こんにちわ、フロントエンドエンジニアのわでぃんです。

とあるサイト制作をしている中でアコーディオンを実装中に、Reactでアニメーション付きのアコーディオンの最適な方法は何かな〜と思い色々調べてみましたが意外と情報が少なかったので自分なりにまとめてみました。

実装方法

今回は、detailssummaryタグで実装する方法と、useState + useRefを用いた方法を紹介します。
他にも方法はありますが、上記の2つがシンプルに実装できるかと思います。

detailsとsummaryタグ

detailssummaryタグの一番いいところはJSいらずで簡単にアコーディオンを実装できるところです。
また、アクセシビリティ対応もしています。
スクリーンリーダーで開閉状態を読み上げたり、キーボード操作などに対応しているため基本的にはaria属性を使わなくてもいいのもメリットだと思います。

HTML Living Standardの標準仕様になっており、実務でも使うことが可能です(IEは除外)。
2023年1月現在の対応ブラウザは下記のようになっています。

参照:Can I use

以下のような記述で簡単にアコーディオン実装ができます。

index.tsx
<details>
  <summary>概要が入ります。</summary>
  詳細テキストが入ります。
</details>

簡単に実装できますが、当然ですがこのままだとアニメーションがありません。
開く時のみのアニメーションでよければ、JSを使わずにcssのみで実装可能です。

detailsタグはopen属性をトグルすることによって表示の切り替えを行うので、開閉時どちらにもアニメーションを適用させる際は、JSが必要になります。
ただ、このopen属性があるせい(?)で実装が少し厄介になります🫠

簡単に要約すると、デフォルトの状態ではopen属性での切り替えをするためアニメーションが効かなくなります(display: noneのイメージ)。
そのためopen属性の付け外しをpreventDefault()で回避し、表示切り替えのアニメーションの処理をします。
アニメーション完了後に、open属性を付与することで開閉時どちらにもアニメーションが可能になります。

コードはこんな感じになります。
スタイリングはemotionを使用しています。

index.tsx
import AccordionItem from "./AccordionItem";

export type AccordionType = {
  overview: string;
  detail: string;
};

const accordionData: AccordionType[] = [
  {
    overview: "概要1",
    detail: "詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト",
  },
  {
    overview: "概要2",
    detail: "詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト",
  },
];

const App = () => {
  return (
    <div>
      {accordionData.map((item, index) => (
        <AccordionItem overview={item.overview} detail={item.detail} key={index} />
      ))}
    </div>
  );
};

export default App;

AccordionItem.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";
import { AccordionType } from "./index";
import React, { useRef } from "react";

const AccordionItem = ({ overview, detail }: AccordionType) => {
  const childElement = useRef<HTMLDivElement>(null);

  const onClickAccordionToggle = (event: React.MouseEvent<HTMLInputElement>) => {
    event.preventDefault(); // デフォルトの制御を無効化
    const details = childElement.current?.parentNode as HTMLDetailsElement;
    const content = details?.querySelector<HTMLDivElement>("summary + div");

    // 閉じる場合のキーフレーム
    const closingAnimation = (content: HTMLElement) => [
      {
        height: `${content.offsetHeight}px`,
        opacity: 1,
      },
      {
        height: 0,
        opacity: 0,
      },
    ];

    // 開く場合のキーフレーム
    const openingAnimation = (content: HTMLElement) => [
      {
        height: 0,
        opacity: 0,
      },
      {
        height: `${content.offsetHeight}px`,
        opacity: 1,
      },
    ];

    // アニメーションタイミング
    const animation = {
      duration: 300,
      easing: "ease-out",
    };

    if (details.open) {
      if (content) {
        content.animate(closingAnimation(content), animation).onfinish = () => {
          details.removeAttribute("open"); // アニメーション完了後に付与する
        };
      }
    } else {
      content?.animate(openingAnimation(content), animation);
      details.setAttribute("open", "true");
    }
  };

  return (
    <details css={details}>
      <summary css={summary} onClick={onClickAccordionToggle}>
        {overview}
      </summary>
      <div css={contents} ref={childElement}>
        {detail}
      </div>
    </details>
  );
};

export default AccordionItem;

const details = css`
  margin-bottom: 40px;
  cursor: pointer;
`;

const summary = css`
  background-color: teal;
  color: #fff;
  padding: 10px;
`;
const contents = css`
  padding: 10px;
  overflow: hidden;
  transition: all 0.4s;
`;

ICS MEDIAさんのブログ記事を参考にしました。
ただ、openの状態や要素の高さをuseStateで管理すると、カクついてしまうため、useRefのみ使用しています。
Reactでもっと簡単に実装できないか検証中のため続報をお待ちください🙇‍♂️

また、注意点としてSafariで挙動が安定しない場合があるようなので、動作確認をしっかり行う必要があります。

useStateとuseRef

次に、useRefを使い高さを取得してJS側で実装する方法です。
今回も、emotionで雑にスタイリングをしています。

index.tsx
import AccordionItem from "./AccordionItem";

export type AccordionType = {
  overview: string;
  detail: string;
};

const accordionData: AccordionType[] = [
  {
    overview: "概要1",
    detail: "詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト",
  },
  {
    overview: "概要2",
    detail: "詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト詳細テキスト",
  },
];

const App = () => {
  return (
    <div>
      {accordionData.map((item, index) => (
        <AccordionItem overview={item.overview} detail={item.detail} key={index} />
      ))}
    </div>
  );
};

export default App;

中のデータと表示するアコーディオンを分離します。
中身のコンポーネントAccordionItem.tsxを作成します。

AccordionItem.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

import { useRef, useState } from "react";
import { AccordionType } from "./index";

const AccordionItem = ({ overview, detail }: AccordionType) => {
  const [showContents, setShowContents] = useState(false);
  const [contentHeight, setContentHeight] = useState(0);
  const childElement = useRef<HTMLDivElement>(null);

  const onClickAccordionToggle = () => {
    if (childElement.current) {
      const childHeight = childElement.current?.clientHeight; // 対象要素の高さの取得
      setContentHeight(childHeight); // 対象要素の高さの代入
      setShowContents(!showContents); // アコーディオン表示
    }
  };

  return (
    <div css={wrapper}>
      <button onClick={onClickAccordionToggle} css={button}>
        {overview}
        {/* showContents: booleanを基に切り替える */}
        <span className={showContents ? "isOpen" : "isClose"} css={touchIcon} />
      </button>
      {/* インラインスタイルで高さの動的変更をする */}
      <div
        style={{
          height: showContents ? `${contentHeight}px` : "0px",
          opacity: showContents ? 1 : 0,
        }}
        css={innerContent}
      >
        <div ref={childElement} className={showContents ? "isOpen" : "isClose"}>
          {detail}
        </div>
      </div>
    </div>
  );
};

const wrapper = css`
  margin-bottom: 40px;
  width: 400px;
`;

const button = css`
  width: 100%;
  height: 50px;
  color: #fff;
  background-color: teal;
  border: none;
  cursor: pointer;
`;

const touchIcon = css`
  transition: height 0.2s linear, opacity 0.2s ease-in;
  overflow: hidden;
  position: relative;

  &::before,
  &::after {
    content: "";
    display: inline-block;
    width: 20px;
    height: 1px;
    position: absolute;
    top: 50%;
    background-color: #fff;
    right: -150px;
  }
  &::after {
    transform: rotate(90deg);
    transition: transform 0.4s ease;
  }

  // オープンの場合
  &.isOpen {
    &::after {
      transform: rotate(0);
    }
  }
`;

const innerContent = css`
  transition: height 0.2s linear, opacity 0.2s ease-in;
  overflow: hidden;
  padding: 10px;
  background-color: whitesmoke;
`;

export default AccordionItem;

アコーディオンの場合、1つのコンポーネントにまとめて書いてもいいかもしれません。
しかし、複数のアコーディオンをそれぞれ独立して動かす場合少しめんどくさくなります。

1つのコンポーネントに全てまとめる場合は以下のような処理になります。

  • バニラJSのように、クリックされた要素を取得してその子要素対してクラス付与や高さの制御
  • 表示させるデータにidを持たせておいてクリックイベントの際にidを渡して、その要素のクラス付与や高さの制御

上記でも可能ですが、useRefで高さを取得し、予めコンポーネント分けして独立させたアコーディオンアイテムにその状態を持たせておくと一番シンプルに書けるなと思いました。

スタイリングはシンプルで、opacityと、状態管理しているheightをインラインスタイルで変更し、transitionで速度やイージングの設定をしています。
また、対象の隠す要素にはoverflow: hiddenを指定します。

ちなみに上記のコードを動かすとこんな感じです。

まとめ

アクセシビリティなども考慮すると、積極的にdetailssummaryタグを使った方がいいかと思いますが、若干クセがあるように感じます。また、safari対応などもあることや、アニメーションの実装のしやすさからしても現状はuseRefを使ってもいいかなと思います。
ただ徐々にdetailssummaryタグに慣れて移行していきたいなと思いました。

Reactで、detailssummaryタグでのいいアニメーション実装方法があればまた更新します🙌

chot Inc. tech blog

Discussion