Reactで、detailsとsummaryを使ってアコーディオンを作る方法

​​React​CSS,HTML,Playwright,TypeScript,useState,アクセシビリティ

<details> と <summary> を使うと、見出しのクリックで開閉する仕組みも、キーボードでの操作も、ブラウザが受け持ちます。React で開閉の状態を state に持たせたいときは、open を渡すだけでなく、onToggle で state をブラウザ側の状態に合わせる必要があります。同じ name を付ければ、1つ開くとほかが閉じるアコーディオンにもなります。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。Firefox と Safari、スクリーンリーダーでの読み上げ、Tab キーでのフォーカス移動は試していません。

「name="cat-care" を付けた3つの details」の画面。「ごはんの時間」をクリックすると開き、「爪切り」をクリックすると「ごはんの時間」が閉じて「爪切り」が開く。「トイレの掃除」も同じように切り替わり、もう一度クリックするとすべて閉じる。下の「React の state」の欄が、そのたびに [food]、[nail]、[toilet]、[] と変わる
name を付けた details を4回クリックした様子。開くのは1つだけで、React の state もクリックのたびに追いつく(クリックの回数と state の欄を足した検証用の画面)

state を持たないなら、details をそのまま書く

開閉の状態を React で使わないなら、JSX に <details> と <summary> を書くだけで動きます。summary をクリックすると開き、もう一度クリックすると閉じます。MDN の details のページにあるとおり、summary にフォーカスして Space を押しても開閉できます。

tsx
// details の中身は、閉じていても描画されたまま
export function KeepInput() {
  return (
    <details>
      <summary>メモを書く</summary>
      <input aria-label="メモ" />
    </details>
  );
}

details の中身は、閉じているあいだも描画されたままです。ブラウザが表示を隠しているだけなので、中の入力欄に入れた値や、コンポーネントの state も消えません。比べるために、ボタンと {open && <input />} で開いているときだけ描画する版も作りました。次は、それぞれの入力欄に「かつお」と入れてから閉じ、もう一度開いたあとの値です。

text
KeepInput: 閉じて開いたあとの入力欄 "かつお"
ConditionalInput: 閉じて開いたあとの入力欄 ""

開いているときだけ描画する版では、入力した文字が消えました。React のドキュメントのstate はツリーの中の位置に結びついているという説明のとおり、描画しなくなったコンポーネントの state を、React は捨てるからです。入力の途中で閉じても内容を残したいフォームなどでは、details を使えば、state を親のコンポーネントに移さずに済みます。

open だけを渡すと、React の state と画面がずれる

「すべて閉じる」ボタンを付けるときなどは、開閉の状態を state に持ち、details の open に渡したくなります。ところが、open だけを渡して onToggle を書かないと、state と画面がずれます。

tsx
// onToggle を書かずに open だけを渡した版
export function OpenWithoutToggle() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button type="button" onClick={() => setOpen(false)}>
        閉じる
      </button>
      <details open={open}>
        <summary>ごはんの時間</summary>
        <p>朝7時と夜7時の2回です。</p>
      </details>
      <output data-testid="state">open: {String(open)}</output>
    </div>
  );
}

summary をクリックしてから「閉じる」ボタンを押し、そのたびに details の open(DOM)と React の state を読みました。

text
最初: DOM 閉 / open: false
summary をクリック: DOM 開 / open: false
「閉じる」をクリック: DOM 開 / open: false

summary のクリックで、ブラウザは details を開きます。React はそれを知らないので、state は false のままです。そのあと「閉じる」で setOpen(false) を呼んでも、state はもともと false です。useState のリファレンスにあるとおり、前と同じ値を渡したときは再レンダリングが省かれます。そのため details は開いたまま残りました。このずれがあっても、コンソールにエラーや警告は出ませんでした。

onToggle で、state をブラウザの開閉に合わせる

ずれをなくすには、details が開閉したときに届く toggle イベントを onToggle で受け取り、event.currentTarget.open を state に入れます。React のドキュメントの共通の props の一覧に、details 用のイベントとして onToggle が載っています。

複数の項目を並べるアコーディオンにしたのが、次のコンポーネントです。開いている項目の id を配列で持ちます。最後の output は、検証で state の中身を読むために置いたものです。

tsx
import { useState, type ReactNode } from 'react';

type Item = {
  id: string;
  title: string;
  body: ReactNode;
};

type Props = {
  items: Item[];
  // 同じ name の details は、1つだけ開く
  name?: string;
};

export function Accordion({ items, name }: Props) {
  const [openIds, setOpenIds] = useState<string[]>([]);

  const setOpen = (id: string, open: boolean) => {
    setOpenIds((prev) => {
      if (open) return prev.includes(id) ? prev : [...prev, id];
      return prev.filter((x) => x !== id);
    });
  };

  return (
    <div className="accordion">
      <div className="toolbar">
        {/* name で1つだけ開く場合は、まとめて開けない */}
        {!name && (
          <button type="button" onClick={() => setOpenIds(items.map((item) => item.id))}>
            すべて開く
          </button>
        )}
        <button type="button" onClick={() => setOpenIds([])}>
          すべて閉じる
        </button>
      </div>
      {items.map((item) => (
        <details
          key={item.id}
          name={name}
          open={openIds.includes(item.id)}
          onToggle={(event) => setOpen(item.id, event.currentTarget.open)}
        >
          <summary>{item.title}</summary>
          <div className="body">{item.body}</div>
        </details>
      ))}
      <output data-testid="state">openIds: [{openIds.join(', ')}]</output>
    </div>
  );
}

name を渡さずに使い、クリック、ボタン、キーボードで開閉しました。字下げした toggle の行は、イベントが届いた details と開閉の変化、そのときの state です。

text
最初: DOM 閉 閉 閉 / openIds: []
「ごはんの時間」をクリック: DOM 開 閉 閉 / openIds: [food]
    toggle 「ごはんの時間」 closed→open(このときの openIds: [])
「爪切り」をクリック: DOM 開 閉 開 / openIds: [food, nail]
「すべて開く」をクリック: DOM 開 開 開 / openIds: [food, toilet, nail]
    toggle 「トイレの掃除」 closed→open(このときの openIds: [food, toilet, nail])
「すべて閉じる」をクリック: DOM 閉 閉 閉 / openIds: []
「トイレの掃除」で Enter: DOM 閉 開 閉 / openIds: [toilet]
「トイレの掃除」で Space: DOM 閉 閉 閉 / openIds: []

どの操作のあとも、DOM と state がそろいました。summary をクリックすると、ブラウザが先に details を開きます。toggle イベントが届いた時点では、state はまだ [] でした。そこで onToggle が state を [food] にしています。MDN の details のページにも、toggle イベントは開閉の状態が変わったあとに送られる、と書かれています。

「すべて開く」のように React から open を変えたときも、toggle イベントは届きました。このときは state がすでに [food, toilet, nail] なので、setOpen の中の更新関数は prev をそのまま返すので、state は変わりません。setOpen の中で includes を確かめているのは、すでに入っている項目を二重に足さず、前と同じ配列をそのまま返すためです。

キーボードでは、summary にフォーカスを移してから押した Enter と Space のどちらでも開閉できました。Chromium のアクセシビリティツリーを見ると、summary は開閉できる要素の役割(DisclosureTriangle)になり、開いているかどうかを expanded で持っていました。

text
[Controlled、「爪切り」を開いたあと]
  role: DisclosureTriangle / name: ごはんの時間 / expanded: false
  role: DisclosureTriangle / name: トイレの掃除 / expanded: false
  role: DisclosureTriangle / name: 爪切り / expanded: true

ボタンで作る場合とは違い、aria-expanded を自分で付け替えなくても、Chromium では開閉の状態がアクセシビリティツリーに出ていました。

name を付けて、1つだけ開くようにする

同じ name を付けた details は、1つを開くとほかが閉じます。MDN の details のページにある、name 属性の説明にあるとおり、スクリプトを書かずにアコーディオンを作るための属性です。上のコンポーネントに name を渡しました。

tsx
export function Exclusive() {
  return <Accordion items={items} name="cat-care" />;
}

順にクリックしたときの DOM と state、届いた toggle イベントです。

text
最初: DOM 閉 閉 閉 / openIds: []
「ごはんの時間」をクリック: DOM 開 閉 閉 / openIds: [food]
    toggle 「ごはんの時間」 closed→open(このときの openIds: [])
「爪切り」をクリック: DOM 閉 閉 開 / openIds: [nail]
    toggle 「爪切り」 closed→open(このときの openIds: [food])
    toggle 「ごはんの時間」 open→closed(このときの openIds: [food, nail])
「トイレの掃除」をクリック: DOM 閉 開 閉 / openIds: [toilet]
    toggle 「トイレの掃除」 closed→open(このときの openIds: [nail])
    toggle 「爪切り」 open→closed(このときの openIds: [nail, toilet])
「トイレの掃除」をもう一度クリック: DOM 閉 閉 閉 / openIds: []
    toggle 「トイレの掃除」 open→closed(このときの openIds: [toilet])

「爪切り」をクリックすると、ブラウザが「ごはんの時間」を閉じ、閉じたほうの details にも toggle イベントが届きました。それぞれの onToggle が state を直すので、1つだけ開くための処理を React 側に書かなくても、state は [nail] になります。

ただし、同じ name の details をまとめて開くことはできません。name を付けたまま「すべて開く」で3つとも open にする版を作って試しました。

text
「すべて開く」をクリック: DOM 閉 閉 開 / openIds: [nail]
    toggle 「ごはんの時間」 closed→closed(このときの openIds: [food, toilet, nail])
    toggle 「爪切り」 closed→open(このときの openIds: [toilet, nail])
    toggle 「トイレの掃除」 closed→closed(このときの openIds: [toilet, nail])

開いたのは最後の「爪切り」だけで、state も onToggle によって [nail] だけになりました。React が上から順に open を付けるたびに、その前に開いたものをブラウザが閉じたと考えられます。「ごはんの時間」と「トイレの掃除」の toggle が closed→closed になっているのは、開いてすぐ閉じたためと考えられます。MDN の toggle イベントのページにあるとおり、イベントループが一巡する前に何度も状態が変わると、toggle イベントは1回にまとめて送られます。

DOM で1つ目から順に open = true にしたときも、残ったのは3つ目でした。一方、HTML に最初から3つとも open を書いた場合は、MDN の details のページの説明のとおり、ソースの順で最初の1つだけが開きました。

text
1→2→3 の順に open = true: 閉 閉 開
HTML で3つとも open を書いた場合: 開 閉 閉

そのため、この記事のコンポーネントは、name を渡されたときは「すべて開く」ボタンを出しません。閉じるほうは1つだけ開くという制約にかからないので、「すべて閉じる」は残しています。

アクセシビリティツリーでの役割は、name を付けると DisclosureTriangleGrouped に変わりました。expanded の値は、付けない場合と同じく開閉に合わせて変わっています。

text
[Exclusive、「爪切り」を開いたあと]
  role: DisclosureTriangleGrouped / name: ごはんの時間 / expanded: false
  role: DisclosureTriangleGrouped / name: トイレの掃除 / expanded: false
  role: DisclosureTriangleGrouped / name: 爪切り / expanded: true

三角を消して、開閉をアニメーションにする

summary の前に出る三角は、MDN の summary のページによると、summary が持つ display: list-item のマーカーです。list-style: none を指定するか、display: block にすると消えます。MDN によると、Safari などの WebKit 系のブラウザでは、::-webkit-details-marker に display: none を指定すると消せます(筆者は Safari では試していません)。この記事では、三角の代わりに ::after で矢印を描き、開いたときに回しています。上の onToggle の節で Enter と Space を試したのは、この節の CSS を当てた状態です。三角を消して display: flex にした状態でも、キーボードで開閉できました。

開閉のアニメーションには、details の中身を指す ::details-content 疑似要素を使います。閉じたときの高さ 0 から auto までを動かすので、interpolate-size: allow-keywords も指定します。MDN では interpolate-size は実験的な機能とされています。対応していないブラウザでは、アニメーションなしで開閉すると考えられます(筆者は試していません)。

css
:root {
  interpolate-size: allow-keywords;
}
.accordion summary {
  list-style: none;
  display: flex;
  justify-content: space-between;
  padding: 12px 8px;
  cursor: pointer;
  font-weight: bold;
}
.accordion summary::-webkit-details-marker {
  display: none;
}
.accordion summary::after {
  content: '';
  width: 8px;
  height: 8px;
  margin: 4px;
  border-right: 2px solid #067a98;
  border-bottom: 2px solid #067a98;
  transform: rotate(45deg);
  transition: transform 200ms;
}
.accordion details[open] > summary::after {
  transform: rotate(-135deg);
}
.accordion details::details-content {
  block-size: 0;
  overflow: hidden;
  transition:
    block-size 300ms,
    content-visibility 300ms allow-discrete;
}
.accordion details[open]::details-content {
  block-size: auto;
}

content-visibility にも allow-discrete 付きで transition を指定しているのは、閉じるときに中身がすぐ消えないようにするためです。MDN の ::details-content のページの例でも、content-visibility に同じように allow-discrete を付けています。

最初の項目を開いてから 0ms・150ms・400ms の details の高さを測り、interpolate-size を既定の numeric-only に戻した場合と比べました。

text
interpolate-size: allow-keywords: 閉 49px / 0ms 49px / 150ms 104px / 400ms 117px
interpolate-size: numeric-only: 閉 49px / 0ms 49px / 150ms 117px / 400ms 117px

allow-keywords では、150ms の時点で開いている途中の高さでした。numeric-only では 150ms の時点でもう開ききっていました。MDN の説明のとおり、既定では auto のような内在サイズへの補間はされないので、高さのアニメーションになりません。

​Posted by 86