Accordion
Sections a reader opens one at a time. Closed panels stay in the HTML, so a crawler reads them and find-in-page opens them.
split - size not measured yet
Accordion
split - size not measured yetSections a reader opens one at a time. Closed panels stay in the HTML, so a crawler reads them and find-in-page opens them.
Try it
hiddenUntilFound
headingLevel
The code
accordion.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
<AccordionRoot className="max-w-105">
<AccordionItem value="server" title="What is a server component?">
<p>A component that renders on the server and sends HTML, with no JavaScript of its own.</p>
</AccordionItem>
<AccordionItem value="arabic" title="Does it work in Arabic?">
<p>Yes. The caret moves to the left edge and the text starts at the right one.</p>
</AccordionItem>
<AccordionItem value="bundle" title="How big is the bundle?">
<p>About one kilobyte in the browser. The answers cost nothing: they are plain HTML.</p>
</AccordionItem>
</AccordionRoot>A closed panel is hidden="until-found": the browser's search, an #id link or a text fragment opens it.
Every state
2 statesImport from the package
topclassui/accordion
import { AccordionRoot, AccordionItem } from 'topclassui/accordion'Install once:npm i topclassui@beta zod
Examples
- Two flat parts and no bare
Accordion:AccordionRootandAccordionItem. An item needsvalueandtitle; its children are the panel. - Open at first:
defaultValue={['install']}on the root, thevalueof each item that starts open. It is plain data, so a server page can pass it. - One at a time is the default, and pressing the open item closes it.
multipleleaves the others as they are. - The title is text or inline elements, such as an icon or a
Badge. No link and no button: it sits inside a button. Put the link in the panel. - A table wider than the box scrolls sideways inside its panel. A long title wraps and is never cut.
hiddenUntilFound={false}on the root takes closed panels out of the page;keepMountedthen keeps them, hidden from the search too.- From a server page, pass text, booleans and
defaultValue.valuewithonValueChange, andonOpenChange, need a'use client'file.
basic
basic.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
/** The drawn sample: three questions, all closed, one open at a time. Each answer is in the
* HTML from the start, hidden until the reader opens it or the browser finds a word in it. */
export default function Demo() {
return (
<AccordionRoot className="max-w-105">
<AccordionItem value="server" title="What is a server component?" headingLevel={2}>
<p>A component that renders on the server and sends HTML, with no JavaScript of its own.</p>
</AccordionItem>
<AccordionItem value="arabic" title="Does it work in Arabic?" headingLevel={2}>
<p>Yes. The caret moves to the left edge and the text starts at the right one.</p>
</AccordionItem>
<AccordionItem value="bundle" title="How big is the bundle?" headingLevel={2}>
<p>About one kilobyte in the browser. The answers cost nothing: they are plain HTML.</p>
</AccordionItem>
</AccordionRoot>
)
}
first-open
first-open.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
/** The drawn state: the first item starts open. defaultValue names it by its value, and is
* plain data, so a server page can pass it. */
export default function Demo() {
return (
<AccordionRoot defaultValue={['install']} className="max-w-105">
<AccordionItem value="install" title="How do I install a component?" headingLevel={2}>
<p>Run npx topclassui add accordion. The files land in your own repository.</p>
</AccordionItem>
<AccordionItem value="arabic" title="Does it work in Arabic?" headingLevel={2}>
<p>Yes. The caret moves to the left edge and the text starts at the right one.</p>
</AccordionItem>
<AccordionItem value="bundle" title="How big is the bundle?" headingLevel={2}>
<p>About one kilobyte in the browser. The answers cost nothing: they are plain HTML.</p>
</AccordionItem>
</AccordionRoot>
)
}
multiple
multiple.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
/** multiple: opening one item leaves the others as they are. Two start open. */
export default function Demo() {
return (
<AccordionRoot multiple defaultValue={['plan', 'seats']} className="max-w-105">
<AccordionItem value="plan" title="Plan" headingLevel={2}>
<p>Team, billed every month.</p>
</AccordionItem>
<AccordionItem value="seats" title="Seats" headingLevel={2}>
<p>Twelve of twenty are in use.</p>
</AccordionItem>
<AccordionItem value="invoices" title="Invoices" headingLevel={2}>
<p>The last one was paid on 2 March.</p>
</AccordionItem>
</AccordionRoot>
)
}
disabled
disabled.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
/** A disabled item between two that work: its trigger keeps its place in the tab order, says
* that it is disabled, and does nothing. Its panel stays closed. */
export default function Demo() {
return (
<AccordionRoot className="max-w-105">
<AccordionItem value="profile" title="Profile" headingLevel={2}>
<p>Your name and your picture.</p>
</AccordionItem>
<AccordionItem value="billing" title="Billing" headingLevel={2} disabled>
<p>Only the owner of the workspace can change the plan.</p>
</AccordionItem>
<AccordionItem value="members" title="Members" headingLevel={2}>
<p>Invite people and choose what each one can do.</p>
</AccordionItem>
</AccordionRoot>
)
}
long-content
long-content.tsx
import { Badge } from 'topclassui/badge'
import { Button } from 'topclassui/button'
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
const WORD = 'a'.repeat(300)
const COLUMNS = ['Order', 'Customer', 'Placed', 'Status', 'Items', 'Total', 'Warehouse', 'Carrier']
const ORDERS = [
['ORD-5521', 'Northwind Traders', '12 March 2026', 'Awaiting payment', '14', '$1,250.00', 'Amman, building 4', 'Aramex'],
['ORD-5519', 'Fabrikam Incorporated', '11 March 2026', 'Awaiting a refund', '120', '$9,120.50', 'Irbid, building 1', 'DHL Express'],
]
/** Hostile content. A title of one 300-letter word wraps beside the caret. A title that is an element
* keeps its badge. A table wider than the box scrolls inside its panel, which is then a tab stop; it
* starts closed, because that needs the browser. An accordion inside a panel takes the next level. */
export default function Demo() {
return (
<AccordionRoot multiple defaultValue={['word', 'element', 'nested']} className="max-w-105">
<AccordionItem value="word" title={WORD} headingLevel={2}>
<Button size="sm" variant="outline" render={<a href="/orders" />}>See every order</Button>
<p className="pt-2">{WORD}</p>
</AccordionItem>
<AccordionItem
value="element"
headingLevel={2}
title={<span className="flex flex-wrap items-center gap-2"><span>Invoices</span><Badge tone="info">3 new</Badge></span>}
>
<p>Three invoices arrived since your last visit.</p>
</AccordionItem>
<AccordionItem value="table" title="Orders that wait for a payment" headingLevel={2}>
<table className="w-max whitespace-nowrap">
<thead>
<tr>
{COLUMNS.map((column) => <th key={column} scope="col" className="pe-6 pb-2 text-start font-medium">{column}</th>)}
</tr>
</thead>
<tbody>
{ORDERS.map((order) => (
<tr key={order[0]}>{order.map((cell) => <td key={cell} className="pe-6 pb-2">{cell}</td>)}</tr>
))}
</tbody>
</table>
</AccordionItem>
<AccordionItem value="nested" title="Shipping" headingLevel={2}>
<AccordionRoot>
<AccordionItem value="inside" title="Inside the country" headingLevel={3}>
<p>Two working days.</p>
</AccordionItem>
<AccordionItem value="abroad" title="Abroad" headingLevel={3}>
<p>Five to nine working days.</p>
</AccordionItem>
</AccordionRoot>
</AccordionItem>
</AccordionRoot>
)
}
many-items
many-items.tsx
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
const QUESTIONS = Array.from({ length: 60 }, (_, index) => index + 1)
/** Sixty items. None is windowed: every answer is in the HTML, which is the point of the
* component, and the page scrolls as any long page does. */
export default function Demo() {
return (
<AccordionRoot className="max-w-105">
{QUESTIONS.map((number) => (
<AccordionItem key={number} value={`question-${String(number)}`} title={`Question ${String(number)}`} headingLevel={2}>
<p>The answer to question {number}.</p>
</AccordionItem>
))}
</AccordionRoot>
)
}
In Arabic, right to left
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
/** Arabic, inside TopClassProvider as the root layout would mount it. Nothing here is told the
* direction: the title starts at the right and the caret sits at the left, the inline end. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<AccordionRoot defaultValue={['server']} className="max-w-105">
<AccordionItem value="server" title="ما هو مكوّن الخادم؟" headingLevel={2}>
<p>مكوّن يُعرض على الخادم ويرسل HTML فقط، دون أي JavaScript خاص به.</p>
</AccordionItem>
<AccordionItem value="arabic" title="هل يعمل بالعربية؟" headingLevel={2}>
<p>نعم. ينتقل السهم إلى الحافة اليسرى ويبدأ النص من اليمنى.</p>
</AccordionItem>
<AccordionItem value="bundle" title="ما حجم الحزمة؟" headingLevel={2}>
<p>نحو كيلوبايت واحد في المتصفح. الإجابات نفسها لا تكلّف شيئًا.</p>
</AccordionItem>
</AccordionRoot>
</div>
</TopClassProvider>
)
}
Props
AccordionRoot
AccordionItem
Keyboard and accessibility
- Set
headingLevelon each item, 2 to 6 (default 3): one more than the heading above the accordion. It is clamped to that range. - A screen reader hears the trigger as a button in a heading, expanded or collapsed, and an open panel as a region named by its title.
- A
disableditem keeps its trigger in the tab order and says that it is disabled. Reduced motion removes the 150 ms fade.
Right to left
Nothing is told the direction: under dir="rtl" the title starts at the right and the caret sits at the left, the inline end.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.