React

Reactでアクセシブルなアコーディオンを作る方法|複数・アニメーション対応

ReactでWAI-ARIAに沿ったアコーディオンを作る方法を解説します。useId、aria-expanded・aria-controls、単一・複数開閉、入れ子、Motionアニメーション、reduced motion、details・Radix UI・MUIとの選び方まで実装例で確認できます。

この記事の目次
  1. WAI-ARIA Accordion Patternの基本
  2. heading levelをpage構造に合わせる
  3. role regionを付けすぎない
  4. useId()でbuttonとpanelを関連付ける
  5. 複数の項目を独立して開閉する
  6. 1つだけ開くcontrolled accordion
  7. Setで複数の開閉状態を親にまとめる
  8. panelをhiddenにするかunmountするか
  9. CSSで見た目を整える
  10. Motionで開閉animationを付ける
  11. prefers-reduced-motionへ対応する
  12. 入れ子のaccordionを作る
  13. arrow key navigationは必要か
  14. native details・summaryを使う選択肢
  15. Radix UI Accordionを使う
  16. MUI Accordionを使う
  17. 自作・native・libraryの選び方
  18. Next.js App Routerで使う場合
  19. 動作確認のチェックリスト
  20. よくあるエラー
  21. buttonを押しても開閉しない
  22. すべてのitemが同時に開く
  23. animationを閉じるときだけ動かない
  24. screen readerで開閉状態が分からない
  25. listを更新すると別itemが開く
  26. panel内の入力値が閉じると消える
  27. まとめ

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のdetailssummaryで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-controlsaria-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-expandedaria-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になります。