A Server Component can render every FUNSTACK UI part directly. Which behaviour works before hydration depends on how much of it the platform does.
"use client"The package is built unbundled: one output file per source file, and every file that uses a client-only API keeps its "use client" directive. The component namespaces (Tabs, Accordion, ...) are plain objects of client references, so this works in a Server Component:
// app/settings.tsx: a Server Component, no "use client"
import { Tabs } from "@funstack/ui/tabs";
export function Settings() {
return (
<Tabs.Root defaultValue="general">
<Tabs.List aria-label="Settings">
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general"><GeneralSettings /></Tabs.Panel>
<Tabs.Panel value="billing"><BillingSettings /></Tabs.Panel>
</Tabs.Root>
);
}Every part is a client module today, including the Accordion and Dialog parts: compound parts find their Root through context, and a Server Component cannot read context. "JS-free" on this site is a statement about behaviour (no JavaScript runs for the interaction, and it works before hydration), not about module boundaries. Directive-free variants are tracked in issue #19; action-less Switch, Checkbox and RadioGroup are strong candidates, since a native input already works without JavaScript.
A Server Component can only pass serialisable props, so it cannot pass a plain function as an action. It can pass a Server Function, which works directly because details serialises cleanly (see Action props):
import { Accordion } from "@funstack/ui/accordion";
import { saveOpenSections } from "./actions"; // "use server"
<Accordion.Root defaultValue={user.openSections} valueChangeAction={saveOpenSections}>
...
</Accordion.Root>| Component | Works before hydration |
|---|---|
| Accordion, JS-free mode | Everything: open and close, Enter and Space, the exclusive group (<details name>), find-in-page, CSS animation. Hydration only adds arrow-key navigation and disabled. |
| Accordion, library mode | The native toggle. Items opened before hydration are reconciled on mount with one valueChangeAction call (reason: "platform"). |
| Tooltip | With experimental_interestFor and experimental_hint in Chromium: the whole tooltip (it is plain HTML). Elsewhere the JS path attaches at hydration. The ARIA relation is in the HTML either way. |
| Tabs | Markup and ARIA. With keepMounted="until-found", hidden panels are in the HTML and searchable. Switching tabs needs JavaScript. |
| Toggle | Markup and ARIA. Pressing needs JavaScript. |
| Dialog, JS-free mode | Everything for a modal dialog: open (command="show-modal"), Escape, Close buttons (command="request-close"), <form method="dialog">, focus and focus return, autoFocus in server-rendered markup (initialFocus after hydration), CSS animation. A Dialog.Form with a Server Function action submits with React's progressive enhancement. Hydration adds initialFocus, lightDismiss and closing after a form action. A non-modal dialog needs JavaScript to open. |
| Dialog, library mode | The Trigger opens the dialog natively, but its content is not in the HTML. On hydration the dialog is reconciled with one openChangeAction(true) call (reason: "platform") and the content mounts. |
| Switch, Checkbox, RadioGroup, CheckboxGroup | They are native inputs: clicking, Space, arrow keys between radios, labels, required and form submission all work. On hydration a changed value is reconciled with one action call (reason: "platform", one per group). Checkbox indeterminate and a mixed CheckboxGroup.Parent are drawn from data-indeterminate but announced as unchecked until hydration; a Parent clicked before hydration counts as "check all" of its values. A required CheckboxGroup keeps the server's required attributes until hydration. |
This FAQ is rendered by a Server Component in JS-free mode. It opens and closes before the page has hydrated, and in any page that works without JavaScript. (This site streams each page behind a Suspense boundary, which needs a small inline script to reveal it, so here it needs JavaScript enabled, but not the library's.)
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>
);
}So is this confirmation dialog. Every button in it is a native invoker or a form submit:
// No "use client": this file is a Server Component. The Dialog.Root has no `open`, `defaultOpen`
// or `openChangeAction`, so it runs in JS-free mode: the Trigger is a native
// `command="show-modal"` invoker, Cancel is a `command="request-close"` button and Delete submits
// a `<form method="dialog">`. The browser opens, focuses, closes on Escape and returns focus,
// before hydration and with JavaScript off.
import { Dialog } from "@funstack/ui/dialog";
export function DialogConfirm() {
return (
<Dialog.Root>
<Dialog.Trigger className="demo-button">Delete draft…</Dialog.Trigger>
<Dialog.Popup className="demo-dialog" role="alertdialog">
<Dialog.Title>Delete this draft?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Form className="demo-dialog-actions">
<Dialog.Close className="demo-button" value="cancel">
Cancel
</Dialog.Close>
<button type="submit" className="demo-button demo-danger" value="delete">
Delete
</button>
</Dialog.Form>
</Dialog.Popup>
</Dialog.Root>
);
}