Built on <details> and <summary>. Without value props it needs no JavaScript at all; with them it becomes a regular FUNSTACK UI state owner with actions, optimistic data-open and Suspense-friendly panels.
A headless React component library built for Async React: transitions by default, action props, Suspense-native content.
No. It renders <details> and <summary>. Try your browser's find-in-page for the word zeppelin: the browser opens the matching item by itself.
Only a zeppelin, to show that closed items stay searchable.
// No "use client": this file is a Server Component. The Accordion has no value props, so it runs
// in JS-free mode: the browser owns <details open>, `exclusive` becomes the native `name` group,
// and opening an item works before hydration (and with JavaScript off).
import { Accordion } from "@funstack/ui/accordion";
const faq = [
{
id: "what",
question: "What is FUNSTACK UI?",
answer:
"A headless React component library built for Async React: transitions by default, action props, Suspense-native content.",
},
{
id: "js",
question: "Does this accordion need JavaScript?",
answer:
"No. It renders <details> and <summary>. Try your browser's find-in-page for the word zeppelin: the browser opens the matching item by itself.",
},
{
id: "zeppelin",
question: "Is anything hidden in here?",
answer: "Only a zeppelin, to show that closed items stay searchable.",
},
];
export function AccordionFaq() {
return (
<Accordion.Root exclusive className="demo-accordion">
{faq.map((item, index) => (
<Accordion.Item
key={item.id}
value={item.id}
defaultOpen={index === 0}
className="demo-accordion-item"
>
<Accordion.Trigger className="demo-accordion-trigger">
<h4>{item.question}</h4>
</Accordion.Trigger>
<Accordion.Panel className="demo-accordion-panel">
<p>{item.answer}</p>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
);
}/* Accordion: <details>/<summary> underneath. `:open` is committed state (both modes);
[data-open] is optimistic state (library mode only). */
.demo-accordion {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.demo-accordion-item + .demo-accordion-item {
border-top: 1px solid var(--color-border);
}
.demo-accordion-trigger {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.6em 0.9em;
cursor: pointer;
list-style: none; /* hides the UA marker; the chevron below replaces it */
}
.demo-accordion-trigger::-webkit-details-marker {
display: none;
}
.demo-accordion-trigger h4 {
flex: 1;
margin: 0;
font-size: 1rem;
}
.demo-accordion-trigger:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.demo-accordion-trigger[data-pending] {
cursor: progress;
}
.demo-accordion-trigger[data-disabled] {
color: GrayText;
cursor: not-allowed;
}
/* Chevron. While an item is pending, the optimistic attribute alone decides, so closing an open
item flips the chevron at once instead of waiting for :open to go. */
.demo-accordion-trigger::before {
content: "";
inline-size: 0.5em;
block-size: 0.5em;
border-inline-end: 2px solid currentColor;
border-block-end: 2px solid currentColor;
rotate: -45deg;
transition: rotate 150ms;
}
.demo-accordion-item:open > .demo-accordion-trigger:not([data-pending])::before,
.demo-accordion-trigger[data-open]::before {
rotate: 45deg;
}
/* Open/close animation on ::details-content (grid-rows form, every engine, both modes). */
.demo-accordion-item::details-content {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 200ms ease,
content-visibility 200ms allow-discrete;
}
.demo-accordion-item:open::details-content {
grid-template-rows: 1fr;
}
.demo-accordion-panel {
min-height: 0; /* the grid item */
overflow: hidden;
padding: 0 0.9em;
}
.demo-accordion-panel :is(p, ul) {
margin: 0 0 0.8em;
}
@media (prefers-reduced-motion: reduce) {
.demo-accordion-item::details-content,
.demo-accordion-trigger::before {
transition: none;
}
}import { Accordion } from "@funstack/ui/accordion";
<Accordion.Root> {/* div: state, keyboard navigation */}
<Accordion.Item value="shipping"> {/* details */}
<Accordion.Trigger> {/* summary: must be the Item's first child */}
<h3>Shipping</h3>
</Accordion.Trigger>
<Accordion.Panel> {/* div: the content */}
We ship worldwide.
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>There is no Header part: the only valid place for a heading is inside the <summary>, so put it in the Trigger. If you use render, the Item must still render a <details> and the Trigger a <summary> (development warning otherwise).
| JS-free mode | Library mode | |
|---|---|---|
| Selected when | Root has none of value, defaultValue, valueChangeAction | Root has any of them |
| Open state owner | the browser | React (committed state) |
| Initial open | Item defaultOpen | Root defaultValue / value |
exclusive | native <details name> group | the library keeps at most one value |
data-open, data-pending | not emitted; style with :open | emitted (optimistic) |
| Works before hydration | yes, fully | yes; reconciled on mount |
transition | no effect (development warning) | animates the commit that ends an action |
See Browser-owned state for the reasoning. Switching modes after mount is not supported.
Accordion.Root| Prop | Type | Default | Description |
|---|---|---|---|
exclusive | boolean | false | At most one item open. Also decides the value type: string | null when exclusive, string[] otherwise. |
value | string[] | string | null (exclusive) | – | Controlled open items. undefined means uncontrolled; [] or null means none open. Selects library mode. |
defaultValue | string[] | string | null (exclusive) | – | Initial open items in uncontrolled library mode. Selects library mode. |
valueChangeAction | ChangeAction<string[] | string | null, AccordionValueChangeDetails> | – | Called with the next open items on every change, inside the Root's transition. Sync, async or a Server Function. details: reason ("activate" or "platform"), item (the item that changed) and open (its new state). In multiple mode the array lists items in the order they mounted. Selects library mode. |
disabled | boolean | false | Disables every item: triggers get aria-disabled="true" and never toggle. |
experimental_interpolateSize | boolean | false | Sets interpolate-size: allow-keywords on the root so ::details-content can transition height to auto. Chromium only today; merged under your style. |
className | string | ((state: AccordionRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: AccordionRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: AccordionRootState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: AccordionRootState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
AccordionRootState: { pending, disabled }. The Root owns onKeyDown.
Accordion.Item| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required in both modes. Identifies the item in the Root's value. |
defaultOpen | boolean | false | JS-free mode only: renders the initial open attribute. Ignored in library mode (development warning). |
disabled | boolean | false | The Trigger gets aria-disabled and never toggles. Enforced by a click handler, so in JS-free mode it does not hold before hydration. |
transition | ViewTransitionClass | – | Library mode only. Forwarded to the <ViewTransition> around the <details>; animates the open/close commit. |
className | string | ((state: AccordionItemState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: AccordionItemState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: AccordionItemState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: AccordionItemState) => ReactNode) | – | Replace the default <details> (element form) or take over rendering (function form). |
AccordionItemState: { value, open, pending, disabled }. open is optimistic, and undefined in JS-free mode (the browser owns it). open, name and onToggle are owned by the library.
Accordion.Trigger| Prop | Type | Default | Description |
|---|---|---|---|
transition | ViewTransitionClass | – | Library mode only. Animates the commit that ends an action (for example a spinner disappearing), not the optimistic data-open flip. |
className | string | ((state: AccordionTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: AccordionTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: AccordionTriggerState) => ReactNode) | – | Rendered from optimistic state; must not suspend. A heading inside is recommended. |
render | ReactElement | ((props, state: AccordionTriggerState) => ReactNode) | – | Replace the default <summary> (element form) or take over rendering (function form). |
AccordionTriggerState is the Item's state. The Trigger owns onClick in library mode (and on disabled items). There, links and form controls inside the summary keep their own behaviour and do not toggle the item.
Accordion.Panel| Prop | Type | Default | Description |
|---|---|---|---|
transition | ViewTransitionClass | – | Library mode only. Animates content that mounts or changes in the commit that ends an action. |
className | string | ((state: AccordionPanelState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: AccordionPanelState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: AccordionPanelState) => ReactNode) | – | Always mounted. The function form receives committed open: use it to mount content lazily. |
render | ReactElement | ((props, state: AccordionPanelState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
AccordionPanelState: { value, open, disabled }, where open is committed (undefined in JS-free mode). Panels have no keepMounted: closed content is always in the DOM, which keeps it searchable.
| Hook | On | Meaning |
|---|---|---|
:open | Item (details) | Committed open state, both modes. |
data-open | Item, Trigger | Optimistic open state (library mode only). |
data-pending | Root | An action is in flight. |
data-pending | Item, Trigger | This item's open state is changing. |
data-disabled | Root, Item, Trigger, Panel | Disabled by prop or by the Root. |
aria-disabled | Trigger | "true" when disabled. |
::details-content | Item | The native content slot: animate it. |
.trigger { list-style: none; } /* hide the UA marker */
.trigger::-webkit-details-marker { display: none; }
.trigger::before { content: "▸"; }
.item:open > .trigger:not([data-pending])::before,
.trigger[data-open]::before { content: "▾"; } /* flips at once in library mode */
/* Open/close animation, every engine, both modes */
.item::details-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease, content-visibility 200ms allow-discrete;
}
.item:open::details-content { grid-template-rows: 1fr; }
.panel { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.item::details-content { transition: none; }
}| Key | Behaviour |
|---|---|
| Enter / Space on a trigger | Toggles the item (native). |
| Tab / Shift+Tab | Every trigger is a tab stop. |
| ArrowDown / ArrowUp | Next / previous trigger in the same Root, wrapping, skipping disabled triggers. |
| Home / End | First / last enabled trigger. |
aria-* is added except aria-disabled.aria-expanded on a summary, so assistive technology hears the committed state; data-open is visual only. For an audible pending state, render an aria-live region from the Trigger's state."use client";
import { use, useState } from "react";
import { Accordion } from "@funstack/ui/accordion";
import { sleep } from "./fake-api.js";
const cache = new Map<string, Promise<string[]>>();
function loadReplies(thread: string): Promise<string[]> {
let promise = cache.get(thread);
if (promise === undefined) {
promise = sleep(1200).then(() => [`First reply to ${thread}`, `Second reply to ${thread}`]);
cache.set(thread, promise);
}
return promise;
}
function Replies({ thread }: { thread: string }) {
const replies = use(loadReplies(thread));
return (
<ul>
{replies.map((reply) => (
<li key={reply}>{reply}</li>
))}
</ul>
);
}
const threads = ["Release planning", "Bug triage", "Design review"];
// Library mode (controlled, exclusive). The Panel's function child mounts <Replies> only once
// the item is open in committed state. Replies suspend for 1.2 s; because opening is a
// transition, the item stays closed with a spinner on its trigger until the data is ready, then
// opens with its content. No Suspense fallback is ever shown.
export function AccordionThreads() {
const [open, setOpen] = useState<string | null>(null);
return (
<>
<Accordion.Root
exclusive
className="demo-accordion"
value={open}
valueChangeAction={(next) => {
setOpen(next); // sync prefix: a Transition update
}}
>
{threads.map((thread) => (
<Accordion.Item key={thread} value={thread} className="demo-accordion-item">
<Accordion.Trigger className="demo-accordion-trigger">
{({ pending }) => (
<>
<h4>{thread}</h4>
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Accordion.Trigger>
<Accordion.Panel className="demo-accordion-panel">
{({ open }) => open && <Replies thread={thread} />}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
<output className="demo-readout">
value: <code>{JSON.stringify(open)}</code>
</output>
</>
);
}/* Accordion: <details>/<summary> underneath. `:open` is committed state (both modes);
[data-open] is optimistic state (library mode only). */
.demo-accordion {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.demo-accordion-item + .demo-accordion-item {
border-top: 1px solid var(--color-border);
}
.demo-accordion-trigger {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.6em 0.9em;
cursor: pointer;
list-style: none; /* hides the UA marker; the chevron below replaces it */
}
.demo-accordion-trigger::-webkit-details-marker {
display: none;
}
.demo-accordion-trigger h4 {
flex: 1;
margin: 0;
font-size: 1rem;
}
.demo-accordion-trigger:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.demo-accordion-trigger[data-pending] {
cursor: progress;
}
.demo-accordion-trigger[data-disabled] {
color: GrayText;
cursor: not-allowed;
}
/* Chevron. While an item is pending, the optimistic attribute alone decides, so closing an open
item flips the chevron at once instead of waiting for :open to go. */
.demo-accordion-trigger::before {
content: "";
inline-size: 0.5em;
block-size: 0.5em;
border-inline-end: 2px solid currentColor;
border-block-end: 2px solid currentColor;
rotate: -45deg;
transition: rotate 150ms;
}
.demo-accordion-item:open > .demo-accordion-trigger:not([data-pending])::before,
.demo-accordion-trigger[data-open]::before {
rotate: 45deg;
}
/* Open/close animation on ::details-content (grid-rows form, every engine, both modes). */
.demo-accordion-item::details-content {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 200ms ease,
content-visibility 200ms allow-discrete;
}
.demo-accordion-item:open::details-content {
grid-template-rows: 1fr;
}
.demo-accordion-panel {
min-height: 0; /* the grid item */
overflow: hidden;
padding: 0 0.9em;
}
.demo-accordion-panel :is(p, ul) {
margin: 0 0 0.8em;
}
@media (prefers-reduced-motion: reduce) {
.demo-accordion-item::details-content,
.demo-accordion-trigger::before {
transition: none;
}
}data-open), the item and trigger get data-pending, and valueChangeAction(next, { reason: "activate", item, open: true }) runs.open: true; the replies suspend, so React holds the commit. The <details> stays closed.data-pending goes.data-open reverts, the item never opens, and the error goes to the nearest Error Boundary.// Server Component
<Accordion.Root defaultValue={user.openSections} valueChangeAction={saveOpenSections}>
...
</Accordion.Root><Accordion.Root exclusive defaultValue={null} valueChangeAction={save}>
<Accordion.Item value="a" transition="grow">...</Accordion.Item>
</Accordion.Root>Use either the transition prop or a ::details-content CSS transition for a given change, not both.