Reactでアクセシブルなアコーディオンを実装する
こんにちわ、フロントエンドエンジニアのわでぃんです。
とあるサイト制作をしている中で、Reactでアニメーション込みのアコーディオンの最適な方法は何かな〜と思い色々調べてみましたが意外と情報が少なかったので自分なりにまとめてみました。
以前の内容
下記は2023/01/19に公開した内容です。
実装方法
今回は、details
とsummary
タグで実装する方法と、useState
+ useRef
を用いた方法を紹介します。他にも方法はありますが、この2つがシンプルに実装できるかと思います。
detailsとsummaryタグ
details
とsummary
タグの一番いいところはJSいらずで簡単にアコーディオンを実装できるところです。
さらにデフォルトでアクセシビリティ対応されています。スクリーンリーダーで開閉状態を読み上げたり、キーボード操作などに対応しているため基本的にはaria属性を使わなくてもいいのもメリットだと思います。
HTML Living Standardの標準仕様になっており、実務でも使うことが可能です(IEは除外)。
2024年10月現在の対応ブラウザは下記のようになっています。
参照:Can I use
以下のような記述で簡単にアコーディオン実装ができます。
<details>
<summary>概要が入ります。</summary>
詳細テキストが入ります。
</details>
簡単に実装できますが、このままだとアニメーションがありません。
開く時のみのアニメーションでよければ、JSを使わずにcssのみで実装可能です。
details
タグはopen
属性をトグルすることによって表示の切り替えを行うので、開閉時どちらにもアニメーションを適用させる際は、JSでの処理が必要になります。
簡単に要約すると、デフォルトの状態ではopen
属性での切り替えをするためアニメーションが効かなくなります(display: none
のイメージ)。
そのためopen
属性の付け外しをpreventDefault()
で回避し、表示切り替えのアニメーションの処理をします。
アニメーション完了後に、open
属性を付与することで開閉時どちらにもアニメーションが可能になります。
コード例は以下のようになります。
あくまで一つのアコーディオンにしか対応できないため、実際はさらにwrapして汎用的に使えるようにする必要があります。
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;
// }
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
.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についてみていきましょう。
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
.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があり何が必要要件になるかがわかりやすいです。
また、アクセシビリティ対応ができているか不安な時は、実際にスクリーンリーダーで聞いてみたり、キーボード操作をしておかしい挙動になっていないかを確かめてみましょう!少しのミスで(スクリーンリーダーユーザーなどが)全くアクセスできなくなることもあるので、フルスクラッチする際は十分注意が必要になります。
まとめ
アクセシビリティなども考慮すると、積極的にdetails
とsummary
タグを使った方がいいかと思いますが、若干クセがあるように感じます。また、safari対応などもあることや、アニメーションの実装のしやすさからしても現状はuseRef
を使ってもいいかなと思います。
ただ徐々にdetails
とsummary
タグに慣れて移行していきたいなと思いました。
Reactで、details
とsummary
タグでのいいアニメーション実装方法があればまた更新します🙌
Discussion