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 ↗

Server Components

A Server Component can render every FUNSTACK UI part directly. Which behaviour works before hydration depends on how much of it the platform does.

Per-file "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.

Actions from the server

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>

Before hydration

ComponentWorks before hydration
Accordion, JS-free modeEverything: 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 modeThe native toggle. Items opened before hydration are reconciled on mount with one valueChangeAction call (reason: "platform").
TooltipWith 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.
TabsMarkup and ARIA. With keepMounted="until-found", hidden panels are in the HTML and searchable. Switching tabs needs JavaScript.
ToggleMarkup and ARIA. Pressing needs JavaScript.
Dialog, JS-free modeEverything 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 modeThe 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, CheckboxGroupThey 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.)

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>
  );
}

So is this confirmation dialog. Every button in it is a native invoker or a form submit:

Delete this draft?

This cannot be undone.

Show code
DialogConfirm.tsx
// 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>
  );
}