🐧

Reactでアクセシブルなアコーディオンを実装する

2023/01/19に公開

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

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

以前の内容

下記は2023/01/19に公開した内容です。
https://gyazo.com/f75850c5e31ed8265c3ba95657309f84

実装方法

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

detailsとsummaryタグ

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

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

参照:Can I use

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

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

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

detailsタグはopen属性をトグルすることによって表示の切り替えを行うので、開閉時どちらにもアニメーションを適用させる際は、JSでの処理が必要になります。

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

コード例は以下のようになります。
あくまで一つのアコーディオンにしか対応できないため、実際はさらにwrapして汎用的に使えるようにする必要があります。

App.tsx
import { AccordionItem } from "./AccordionItem";
import styles from "./App.module.css";

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

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

export default function App() {
  return (
    <div className={styles.container}>
      <h1>useRef + useState</h1>
      <div className={styles.accordionArea}>
        {accordionData.map((item) => (
          <AccordionItem {...item} key={item.id} />
        ))}
      </div>
    </div>
  );
}

// App.module.css
// .accordionArea {
//  display: grid;
//  gap: 32px;
// }

AccordionItem.tsx
import { type AccordionType } from "./App";
import styles from "./AccordionItem.module.css";
import { type MouseEvent, useRef } from "react";

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

  const onClickAccordionToggle = (event: 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",
    } satisfies KeyframeAnimationOptions;

    if (!details.open) {
      content?.animate(openingAnimation(content), animation);
      details.setAttribute("open", "true");
      return;
    }
    if (content) {
      content.animate(closingAnimation(content), animation).onfinish = () => {
        details.removeAttribute("open");
      };
      return;
    }
  };

  return (
    <details className={styles.details} id={`accordion-${id}`}>
      <summary className={styles.summary} onClick={onClickAccordionToggle}>
        {overview}
      </summary>
      <div className={styles.contents} ref={childElement}>
        {detail}
      </div>
    </details>
  );
};
AccordionItem.module.css
AccordionItem.module.css
.details {
  cursor: pointer;
}
.summary {
  background-color: teal;
  color: #fff;
  padding: 10px;
}
.contents {
  overflow: hidden;
}

アニメーション周りについては、ICS MEDIAさんのブログ記事を参考にしました。
注意点としてSafariで挙動が安定しない場合があるようなので、動作確認をしっかり行う必要があります。

useStateとuseRef

次に、useRefを使い高さを取得してJS側で実装する方法です。
フルスクラッチで作る場合は、アクセシビリティ対応もしっかりしておきましょう!

*App.tsxは、summary/detailsと同じため省略します

AccordionItem.tsxについてみていきましょう。

AccordionItem.tsx
import styles from './AccordionItem.module.css';
import { useRef, useState } from 'react';
import { type AccordionType } from './App';

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

  const onClickAccordionToggle = () => {
    if (!childElement.current) return;
    const childHeight = childElement.current?.clientHeight;
    setContentHeight(childHeight);
    setShowContents(!showContents);
  };

  return (
    <div className={styles.wrapper}>
      <button
        onClick={onClickAccordionToggle}
        className={styles.button}
        aria-expanded={showContents}
        aria-controls={`accordion-content-${id}`}
        id={`accordion-button-${id}`}
      >
        {overview}
        <span className={styles.touchIcon} data-is-open={showContents} />
      </button>
      <div
        role='region'
        id={`accordion-content-${id}`}
        aria-labelledby={`accordion-button-${id}`}
        // NOTE: 動的な高さの変更はインラインスタイルで行う
        style={{
          height: showContents ? `${contentHeight}px` : '0px',
          opacity: showContents ? 1 : 0,
        }}
        className={styles.innerContent}
        aria-hidden={!showContents}
      >
        <div ref={childElement} data-is-open={showContents}>
          {detail}
        </div>
      </div>
    </div>
  );
};
AccordionItem.module.css
AccordionItem.module.css
.wrapper {
  margin-bottom: 40px;
  width: 400px;
}

.button {
  width: 100%;
  height: 50px;
  color: #fff;
  background-color: teal;
  border: none;
  cursor: pointer;
}

.touchIcon {
  transition: height 0.2s linear, opacity 0.2s ease-in;
  overflow: hidden;
  position: relative;
}

.touchIcon::before,
.touchIcon::after {
  content: "";
  display: inline-block;
  width: 20px;
  height: 1px;
  position: absolute;
  top: 50%;
  background-color: #fff;
  right: -160px;
}

.touchIcon::after {
  transform: rotate(90deg);
  transition: transform 0.4s ease;
}

.touchIcon[data-is-open="true"]::after {
  transform: rotate(0);
}

.innerContent {
  transition: height 0.2s linear, opacity 0.2s ease-in;
  overflow: hidden;
  padding: 10px;
  background-color: whitesmoke;
}

コードを見るとわかるように、高さと状態をuseStateで持っているだけですのでシンプルにかけますね。スタイリングもシンプルで、opacityと、状態管理しているheightはインラインスタイルで変更し、transitionで速度やイージングの設定をしています。それ以外はCSS Modulesに記載しています。また、対象の隠す要素にはoverflow: hiddenを指定しておきましょう。

繰り返しになりますが、上記はアコーディオンの実装の一例になります。
実際は、Accordionコンポーネントを拡張できるように、providerを張って親側で状態を管理できるようにするなど複雑なコンポーネントになりがちです。

サイト制作などの簡易的なアコーディオンであれば、そこまで意識せずにコンポーネントを作ってしまってOKだと思いますが、webアプリケーションではなるべくフルスクラッチせずにライブラリに頼ることをおすめします。
最近では、RadixUIなどのヘッドレスUIを使うことでアクセシブルでメンテナンスもしやすい設計にすることが可能ですので、積極的に活用しましょう。

Chakra UIのアコーディオンなどを見ると、どのようなAPIがあり何が必要要件になるかがわかりやすいです。
https://v2.chakra-ui.com/docs/components/accordion

また、アクセシビリティ対応ができているか不安な時は、実際にスクリーンリーダーで聞いてみたり、キーボード操作をしておかしい挙動になっていないかを確かめてみましょう!少しのミスで(スクリーンリーダーユーザーなどが)全くアクセスできなくなることもあるので、フルスクラッチする際は十分注意が必要になります。

まとめ

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

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

Discussion