Reactで、detailsとsummaryを使ってアコーディオンを作る方法
<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]、[] と変わる](https://neko-note.org/wp-content/uploads/2026/10/react-details-accordion-details-accordion.gif)
state を持たないなら、details をそのまま書く
開閉の状態を React で使わないなら、JSX に <details> と <summary> を書くだけで動きます。summary をクリックすると開き、もう一度クリックすると閉じます。MDN の details のページにあるとおり、summary にフォーカスして Space を押しても開閉できます。
// details の中身は、閉じていても描画されたまま
export function KeepInput() {
return (
<details>
<summary>メモを書く</summary>
<input aria-label="メモ" />
</details>
);
}
details の中身は、閉じているあいだも描画されたままです。ブラウザが表示を隠しているだけなので、中の入力欄に入れた値や、コンポーネントの state も消えません。比べるために、ボタンと {open && <input />} で開いているときだけ描画する版も作りました。次は、それぞれの入力欄に「かつお」と入れてから閉じ、もう一度開いたあとの値です。
KeepInput: 閉じて開いたあとの入力欄 "かつお"
ConditionalInput: 閉じて開いたあとの入力欄 ""
開いているときだけ描画する版では、入力した文字が消えました。React のドキュメントのstate はツリーの中の位置に結びついているという説明のとおり、描画しなくなったコンポーネントの state を、React は捨てるからです。入力の途中で閉じても内容を残したいフォームなどでは、details を使えば、state を親のコンポーネントに移さずに済みます。
open だけを渡すと、React の state と画面がずれる
「すべて閉じる」ボタンを付けるときなどは、開閉の状態を state に持ち、details の open に渡したくなります。ところが、open だけを渡して onToggle を書かないと、state と画面がずれます。
// 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 を読みました。
最初: 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 の中身を読むために置いたものです。
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 です。
最初: 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 で持っていました。
[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 を渡しました。
export function Exclusive() {
return <Accordion items={items} name="cat-care" />;
}
順にクリックしたときの DOM と state、届いた toggle イベントです。
最初: 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 にする版を作って試しました。
「すべて開く」をクリック: 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つだけが開きました。
1→2→3 の順に open = true: 閉 閉 開
HTML で3つとも open を書いた場合: 開 閉 閉
そのため、この記事のコンポーネントは、name を渡されたときは「すべて開く」ボタンを出しません。閉じるほうは1つだけ開くという制約にかからないので、「すべて閉じる」は残しています。
アクセシビリティツリーでの役割は、name を付けると DisclosureTriangleGrouped に変わりました。expanded の値は、付けない場合と同じく開閉に合わせて変わっています。
[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 は実験的な機能とされています。対応していないブラウザでは、アニメーションなしで開閉すると考えられます(筆者は試していません)。
: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 に戻した場合と比べました。
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 のような内在サイズへの補間はされないので、高さのアニメーションになりません。





