Reactでアコーディオンを作るときは、開閉できるだけでなく、buttonとpanelの関係をHTML・ARIAで正しく表すことが重要です。
最小構成では次を使います。
- headerをnative
<button>にする aria-expandedへ開閉状態を渡すaria-controlsでpanel IDを参照する- panelの
aria-labelledbyでbutton IDを参照する useId()で同じcomponentを複数置いても重複しないIDを作る
import { useId, useState } from "react";
export function AccordionItem({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
return (
<section className="accordion-item">
<h3>
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen((current) => !current)}
>
{title}
</button>
</h3>
<div
id={panelId}
aria-labelledby={buttonId}
hidden={!isOpen}
>
{children}
</div>
</section>
);
}
このcomponentを基準に、複数開閉、単一開閉、animation、入れ子へ拡張します。
WAI-ARIA Accordion Patternの基本
W3CのAccordion Patternでは、accordion headerは関連panelを表示・非表示にするcontrolです。
| 要素・属性 | 役割 |
|---|---|
| heading内のbutton | panelを開閉するfocus可能なcontrol |
aria-expanded |
現在開いているかをbooleanで伝える |
aria-controls |
buttonが操作するpanelのIDを示す |
aria-labelledby |
panelをlabelするbuttonのIDを示す |
role="region" |
panelをlandmarkとして扱う必要がある場合だけ任意で使用 |
native buttonはEnterとSpaceでactivateでき、Tab / Shift + Tabの通常focus順にも入ります。div role="button"を自作するよりbuttonを使います。
heading levelをpage構造に合わせる
accordion titleはheadingの中へbuttonだけを置く構造が基本です。記事内のsectionがH2ならaccordion itemをH3にするなど、page outlineに合うlevelを選びます。
再利用componentでlevelを変えたい場合はheadingLevelを受け取る設計もできますが、任意tag生成が複雑ならpage側でheading wrapperを渡すほうが明確です。
role regionを付けすぎない
panelにheadingがある、nested accordionを含むなど、構造を見つけやすくする必要がある場合はrole="region"が役立ちます。ただし、同時に展開できるpanelが多数あるとlandmarkが増えすぎます。WAI-ARIA APGは、おおむね6つを超えるpanelが同時展開されるような場合にregionの乱用を避けるよう案内しています。
useId()でbuttonとpanelを関連付ける
IDをaccordion-1のように固定すると、componentを複数renderしたときに重複します。ReactのuseId()はaccessibility属性へ渡すunique IDを作るためのHookです。
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
useId()はlistのkeyを作るためには使いません。list keyはAPIやdataにあるstableなIDを使います。
server renderingではserverとclientのcomponent treeが一致している必要があります。conditional renderの差でhydration treeを変えないようにします。
複数の項目を独立して開閉する
各item自身がuseStateを持てば、複数panelを同時に開けます。
const faqItems = [
{
id: "shipping",
question: "送料はいくらですか?",
answer: "全国一律800円です。",
},
{
id: "returns",
question: "返品できますか?",
answer: "到着後7日以内にご連絡ください。",
},
];
export default function FAQ() {
return (
<div className="accordion">
{faqItems.map((item) => (
<AccordionItem key={item.id} title={item.question}>
<p>{item.answer}</p>
</AccordionItem>
))}
</div>
);
}
indexをkeyにすると、itemの追加・削除・並べ替えで別itemのstateが再利用される可能性があります。data固有のidを使います。
1つだけ開くcontrolled accordion
同時に1panelだけ開く場合は、groupの親componentがopenIdを管理します。
import { useId, useState } from "react";
function ControlledItem({ item, isOpen, onToggle }) {
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
return (
<section className="accordion-item">
<h3>
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
>
{item.question}
</button>
</h3>
<div
id={panelId}
aria-labelledby={buttonId}
hidden={!isOpen}
>
<p>{item.answer}</p>
</div>
</section>
);
}
export default function SingleAccordion({ items }) {
const [openId, setOpenId] = useState(null);
return (
<div className="accordion">
{items.map((item) => (
<ControlledItem
key={item.id}
item={item}
isOpen={openId === item.id}
onToggle={() => {
setOpenId((current) =>
current === item.id ? null : item.id,
);
}}
/>
))}
</div>
);
}
current === item.id ? null : item.idにより、開いているitemを再度押すとすべて閉じられます。常に1つ開く要件なら、同じIDを押したときにnullへ戻さないよう変更し、開いたbuttonへaria-disabled="true"を付ける設計もあります。
Setで複数の開閉状態を親にまとめる
外部の「すべて開く」buttonや、URL・保存状態と同期したい場合は、親でopen IDのSetを管理します。
const [openIds, setOpenIds] = useState(() => new Set());
function toggleItem(id) {
setOpenIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
stateのSetを直接変更せず、新しいSetを返します。各itemへisOpen={openIds.has(item.id)}とonToggle={() => toggleItem(item.id)}を渡します。
URL同期や初期値、全開閉まで含めて状態管理が大きくなったら、開閉ロジックをReactのカスタムフックへ切り出すと、表示componentと責務を分けられます。
panelをhiddenにするかunmountするか
閉じたpanelの扱いにはtradeoffがあります。
| 方法 | 利点 | 注意点 |
|---|---|---|
hidden={!isOpen} |
DOMとReact stateを保持 | height transitionはそのままではできない |
{isOpen && <Panel />} |
閉じた内容をmountしない | form入力・local stateを失い、exit animationできない |
| animation libraryでpresence管理 | close animation後にunmount可能 | dependencyとbundleが増える |
重いcomponent treeでなければ、baselineはhiddenで十分です。閉じたpanel内のform stateを保持したいか、browser内検索で見つけたいか、SSR・SEO要件があるかも確認します。
conditional renderingの文法はReactの条件分岐で解説しています。
CSSで見た目を整える
開閉機能とARIAを先に動かし、その後CSSを追加します。
.accordion-item {
border-block-end: 1px solid #d4d4d4;
}
.accordion-item h3 {
margin: 0;
}
.accordion-item button {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 700;
text-align: start;
cursor: pointer;
}
.accordion-item button::after {
content: "+";
}
.accordion-item button[aria-expanded="true"]::after {
content: "−";
}
.accordion-item button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 4px;
}
.accordion-item [aria-labelledby] {
padding-block-end: 1rem;
}
.accordion-panel-motion {
overflow: hidden;
}
iconだけで状態を示さず、aria-expandedを必ず同期します。React Iconsを使う場合も、decorative iconはaria-hidden="true"にします。導入方法はReact Iconsの使い方を参照してください。
Motionで開閉animationを付ける
現行Motion for Reactはmotionpackageをinstallし、motion/reactからimportします。
npm install motion
AnimatePresenceをconditionalの外側へ置き、close時のexitが終わるまでpanelをDOMへ残します。
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { useId, useState } from "react";
export function AnimatedAccordionItem({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
return (
<section className="accordion-item">
<h3>
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen((current) => !current)}
>
{title}
</button>
</h3>
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="panel"
id={panelId}
aria-labelledby={buttonId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.24 }}
className="accordion-panel-motion"
>
<div className="accordion-panel-inner">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</MotionConfig>
</section>
);
}
Motionはheight: 0からautoを計測してanimateできます。close中も短時間panelが残る点を考慮し、buttonからcloseする通常操作を基本にします。panel内から別controlでcloseする場合は、close後のfocus移動も設計してください。
prefers-reduced-motionへ対応する
MotionのreducedMotion="user"に加え、CSS transitionを使う場合はmedia queryで短縮・無効化します。
@media (prefers-reduced-motion: reduce) {
.accordion-item *,
.accordion-item *::before,
.accordion-item *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
motion preferenceは「すべての動きを削除しなければならない」という単純なruleではありませんが、non-essentialなheight・rotation animationは減らせるようにします。
入れ子のaccordionを作る
panelのchildrenへ別のAccordionItemを置けばnested accordionになります。
<AccordionItem title="アカウント設定">
<p>アカウント全体の設定です。</p>
<AccordionItem title="メール通知">
<p>通知frequencyを変更できます。</p>
</AccordionItem>
</AccordionItem>
useId()によりIDは重複しません。nested heading levelは外側より1つ深くし、panelへrole="region"を付ける場合はlandmark数を確認します。
入れ子が3段・4段と深くなるなら、情報architectureや別page・tabsのほうが理解しやすくないか見直します。
arrow key navigationは必要か
WAI-ARIA APGではEnter・Spaceと通常のTab移動が基本です。実装によってはArrow Up / Down、Home、Endでheader間を移動できます。
arrow navigationを追加する場合は、header buttonのref一覧、disabled item、wrapの有無、nested groupとの境界を一貫して実装します。単純なFAQではnative Tab navigationだけでもpatternの基本操作を満たせるため、不完全なcustom keyboard操作を足さない選択も合理的です。
複雑なkeyboard navigationが必要なら、WAI-ARIA patternを実装済みのlibraryを使うほうが安全です。
native details・summaryを使う選択肢
custom state・animationが不要ならHTMLのdetailsとsummaryでdisclosureを作れます。
<details>
<summary>送料はいくらですか?</summary>
<p>全国一律800円です。</p>
</details>
ReactではそのままJSXとして使えます。browserが開閉stateとkeyboard操作を処理するため、FAQや補足説明では最小の依存で済みます。
複数のdetailsへ同じnameを付けてexclusive accordionにする方法もありますが、browser要件と既存support範囲を確認してください。application stateと厳密に同期する、複雑なanimationを付ける、design system APIへ統合する場合はReact componentが適します。
Radix UI Accordionを使う
unstyled primitiveをdesign systemへ組み込みたい場合はRadix UIが候補です。WAI-ARIA pattern、single / multiple、controlled / uncontrolled、keyboard navigation、animation用CSS変数を提供します。
npm install radix-ui
import { Accordion } from "radix-ui";
export default function Example() {
return (
<Accordion.Root type="single" collapsible>
<Accordion.Item value="shipping">
<Accordion.Header>
<Accordion.Trigger>送料はいくらですか?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
全国一律800円です。
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}
projectが個別package @radix-ui/react-accordionを採用している場合は、そのversionの公式importへ合わせます。既存dependency方針を確認してから選びます。
MUI Accordionを使う
すでにMaterial UIを採用しているprojectでは、MUIのAccordionを使うとtheme・spacing・transitionを統一できます。
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export default function Example() {
return (
<Accordion>
<AccordionSummary
id="shipping-header"
aria-controls="shipping-content"
expandIcon={<ExpandMoreIcon />}
>
送料はいくらですか?
</AccordionSummary>
<AccordionDetails>
全国一律800円です。
</AccordionDetails>
</Accordion>
);
}
大量・重いpanelでは、MUIが閉じたcontentをdefaultでmountする挙動とunmountOnExitのtradeoffを公式資料で確認します。
自作・native・libraryの選び方
| 選択 | 向いている状況 |
|---|---|
details |
単純なFAQ・補足でcustom stateが不要 |
| 自作React | 要件が小さく、markup・state・testを自分で維持できる |
| Radix UI | unstyled primitiveを独自design systemへ組み込む |
| MUI | 既にMaterial UIを採用し、themeを統一する |
| Motion追加 | closeを含むheight・opacity animationが要件 |
accordionだけのために大きなUI libraryを追加せず、projectに既にあるdesign system、bundle、SSR、accessibility test、maintenanceを含めて判断します。
Next.js App Routerで使う場合
useStateとclick handlerを使うaccordionはClient Componentです。Next.js App Routerではfile先頭へ"use client"を追加します。
"use client";
import { useId, useState } from "react";
accordionへ渡すtitle・本文dataはServer Componentで取得し、serializable propsとしてClient Componentへ渡せます。すべてのpageをClient Componentへ変える必要はありません。
動作確認のチェックリスト
mouse clickだけで完了にせず、次を確認します。
- Tabで全header buttonへ順番にfocusできる
- EnterとSpaceで開閉できる
aria-expandedがtrue / falseへ変わるaria-controlsとpanel IDが一致する- IDがpage内で重複しない
- close時にfocusが消えない
- nested accordionでもheading levelが自然
- reduced motion設定で不要なanimationが抑制される
- 並べ替え後もstable keyにより正しいitemが開く
- screen readerと自動accessibility検査で確認する
React Testing Libraryではbuttonをaccessible nameで取得し、click前後のaria-expandedとpanel表示をtestできます。実browserのkeyboard・screen reader確認も省略しないでください。
よくあるエラー
buttonを押しても開閉しない
onClickがbuttonへ付いているか、state updaterが新しい値を返すか、parentからcontrolled propsを渡し忘れていないか確認します。
すべてのitemが同時に開く
1つのbooleanを全itemで共有しています。独立開閉ならitemごとのstate、単一開閉ならopenId、複数開閉ならIDのSetを使います。
animationを閉じるときだけ動かない
conditional renderingでelementが即unmountされています。AnimatePresenceをconditionalの外側に置き、childへstableなkeyとexitを設定します。
screen readerで開閉状態が分からない
buttonのaria-expandedとpanel IDを確認します。clickable divではなくnative buttonを使い、buttonとpanelをaria-controls・aria-labelledbyで関連付けます。
listを更新すると別itemが開く
index keyを使っている可能性があります。data固有のstable IDをkeyとopen stateに使います。
panel内の入力値が閉じると消える
閉じるたびにpanelをunmountしています。stateを親へ移すか、hiddenでDOMを保持する設計へ変更します。
まとめ
React accordionは、stateだけでなくHTML semanticsとARIAを同時に設計します。
- headerはheading内のnative buttonにする
aria-expandedとaria-controlsをstateに同期するuseId()でbutton・panel IDの重複を防ぐ- 単一開閉はopen ID、複数開閉はitem stateまたはID Setで管理する
- animationにはexitとreduced motionを含める
- 単純なFAQはnative detailsも検討する
- 複雑なkeyboard要件はRadix UIや既存design systemを活用する
clickできるだけで完成とせず、keyboard、focus、screen reader、再ordering、SSR、panel stateの保持まで確認すると、実運用で壊れにくいaccordionになります。