FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Accordion

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.

What is FUNSTACK UI?

A headless React component library built for Async React: transitions by default, action props, Suspense-native content.

Does this accordion need JavaScript?

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.

Is anything hidden in here?

Only a zeppelin, to show that closed items stay searchable.

Show code
AccordionFaq.tsx
// 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.css
/* 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;
  }
}

Anatomy

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).

Two modes

JS-free modeLibrary mode
Selected whenRoot has none of value, defaultValue, valueChangeActionRoot has any of them
Open state ownerthe browserReact (committed state)
Initial openItem defaultOpenRoot defaultValue / value
exclusivenative <details name> groupthe library keeps at most one value
data-open, data-pendingnot emitted; style with :openemitted (optimistic)
Works before hydrationyes, fullyyes; reconciled on mount
transitionno effect (development warning)animates the commit that ends an action

See Browser-owned state for the reasoning. Switching modes after mount is not supported.

Props

Accordion.Root

PropTypeDefaultDescription
exclusivebooleanfalseAt most one item open. Also decides the value type: string | null when exclusive, string[] otherwise.
valuestring[] | string | null (exclusive)–Controlled open items. undefined means uncontrolled; [] or null means none open. Selects library mode.
defaultValuestring[] | string | null (exclusive)–Initial open items in uncontrolled library mode. Selects library mode.
valueChangeActionChangeAction<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.
disabledbooleanfalseDisables every item: triggers get aria-disabled="true" and never toggle.
experimental_interpolateSizebooleanfalseSets interpolate-size: allow-keywords on the root so ::details-content can transition height to auto. Chromium only today; merged under your style.
classNamestring | ((state: AccordionRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: AccordionRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: AccordionRootState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
valuestring–Required in both modes. Identifies the item in the Root's value.
defaultOpenbooleanfalseJS-free mode only: renders the initial open attribute. Ignored in library mode (development warning).
disabledbooleanfalseThe Trigger gets aria-disabled and never toggles. Enforced by a click handler, so in JS-free mode it does not hold before hydration.
transitionViewTransitionClass–Library mode only. Forwarded to the <ViewTransition> around the <details>; animates the open/close commit.
classNamestring | ((state: AccordionItemState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: AccordionItemState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: AccordionItemState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
transitionViewTransitionClass–Library mode only. Animates the commit that ends an action (for example a spinner disappearing), not the optimistic data-open flip.
classNamestring | ((state: AccordionTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: AccordionTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: AccordionTriggerState) => ReactNode)–Rendered from optimistic state; must not suspend. A heading inside is recommended.
renderReactElement | ((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

PropTypeDefaultDescription
transitionViewTransitionClass–Library mode only. Animates content that mounts or changes in the commit that ends an action.
classNamestring | ((state: AccordionPanelState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: AccordionPanelState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: AccordionPanelState) => ReactNode)–Always mounted. The function form receives committed open: use it to mount content lazily.
renderReactElement | ((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.

Data attributes and pseudo-classes

HookOnMeaning
:openItem (details)Committed open state, both modes.
data-openItem, TriggerOptimistic open state (library mode only).
data-pendingRootAn action is in flight.
data-pendingItem, TriggerThis item's open state is changing.
data-disabledRoot, Item, Trigger, PanelDisabled by prop or by the Root.
aria-disabledTrigger"true" when disabled.
::details-contentItemThe 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; }
}

Keyboard and ARIA

KeyBehaviour
Enter / Space on a triggerToggles the item (native).
Tab / Shift+TabEvery trigger is a tab stop.
ArrowDown / ArrowUpNext / previous trigger in the same Root, wrapping, skipping disabled triggers.
Home / EndFirst / last enabled trigger.
  • The summary's disclosure role and expanded state are native. No aria-* is added except aria-disabled.
  • HTML forbids 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.
  • Disabled triggers stay focusable. Focus never moves on open or close.

Async walkthrough (library mode)

Release planning

Bug triage

Design review

value: null
Show code
AccordionThreads.tsx
"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.css
/* 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;
  }
}
  1. Click a trigger: the click is intercepted, the chevron flips (data-open), the item and trigger get data-pending, and valueChangeAction(next, { reason: "activate", item, open: true }) runs.
  2. The commit renders the Panel's function child with open: true; the replies suspend, so React holds the commit. The <details> stays closed.
  3. The data arrives: the item opens with its content in one commit, and data-pending goes.
  4. If the action rejects, data-open reverts, the item never opens, and the error goes to the nearest Error Boundary.

Find-in-page in library mode

The browser opens a closed item when find-in-page or a text fragment matches its content. The Accordion dispatches that as reason: "platform" with the toggle event. If a controlled parent does not adopt the change, the item is closed again when the action settles. Disabled items are re-closed without dispatching.

Recipes

Persist open sections with a Server Function

// Server Component
<Accordion.Root defaultValue={user.openSections} valueChangeAction={saveOpenSections}>
  ...
</Accordion.Root>

Animate item boxes with ViewTransition

<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.