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