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 ↗

RadioGroup

A radio group (WAI-ARIA Radio Group pattern, native radio variant): a <fieldset> of real <input type="radio"> elements. The platform supplies the single tab stop, arrow keys (wrapping and RTL-mirrored outside Safari), forms and validation. The library adds one thing: the checked value is changed through valueChangeAction in a transition, with optimistic feedback and data-pending.

Theme
Show code
RadioGroupTheme.tsx
"use client";
import { RadioGroup } from "@funstack/ui/radio-group";
import { fakeSave } from "./fake-api.js";

const themes = [
  { value: "light", label: "Light" },
  { value: "dark", label: "Dark" },
  { value: "system", label: "System" },
];

// Uncontrolled with an async save. The clicked radio checks at once (its DOM `checked` is the
// optimistic value); the Root and that radio carry data-pending for the 800 ms the save takes.
// The legend reads `pending` through the Label's function children; the row spinner is CSS on
// `:has([data-pending])`. Arrow keys are native: each press checks and saves, the latest wins.
export function RadioGroupTheme() {
  return (
    <RadioGroup.Root
      className="demo-radio-group"
      defaultValue="system"
      valueChangeAction={(next) => fakeSave(`theme=${next}`)}
    >
      <RadioGroup.Label>
        {({ pending }) => (
          <>Theme {pending && <span className="demo-radio-saving">saving…</span>}</>
        )}
      </RadioGroup.Label>
      {themes.map((theme) => (
        <label key={theme.value} className="demo-field">
          <RadioGroup.Item value={theme.value} className="demo-radio" />
          {theme.label}
          <span className="demo-spinner demo-radio-spinner" aria-hidden />
        </label>
      ))}
    </RadioGroup.Root>
  );
}
radio-group.css
/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
   (appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
   optimistic value. */
.demo-radio-group {
  display: grid;
  gap: 0.4rem;
  justify-items: start;
  margin: 0;
  padding: 0.5rem 1rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

.demo-radio-group legend {
  padding-inline: 0.3rem;
  font-weight: 600;
  color: var(--color-text);
}

.demo-radio-group[data-pending] {
  cursor: progress;
}

.demo-radio-inline {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
}

.demo-radio {
  appearance: none;
  flex: none;
  margin: 0;
  inline-size: 1.25em;
  block-size: 1.25em;
  border: 2px solid var(--color-text-secondary);
  border-radius: 50%;
  display: inline-grid;
  place-content: center;
  cursor: inherit;
  transition: border-color 120ms;
}

.demo-radio::before {
  content: "";
  inline-size: 0.6em;
  block-size: 0.6em;
  border-radius: 50%;
  background: var(--color-primary);
  scale: 0;
  transition: scale 120ms;
}

.demo-radio:checked {
  border-color: var(--color-primary);
}

/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
  scale: 1;
}

.demo-radio:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.demo-radio:user-invalid {
  border-color: #dc2626;
}

.demo-field:has(.demo-radio:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
  display: none;
}

.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
  display: inline-block;
}

.demo-radio-saving {
  font-weight: normal;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .demo-radio,
  .demo-radio::before {
    transition: none;
  }
}

Anatomy

import { RadioGroup } from "@funstack/ui/radio-group";

<RadioGroup.Root name="plan" defaultValue="free" valueChangeAction={savePlan}> {/* fieldset */}
  <RadioGroup.Label>Plan</RadioGroup.Label>          {/* legend: optional, first child */}
  <label>
    <RadioGroup.Item value="free" /> Free              {/* input type=radio */}
  </label>
  <label>
    <RadioGroup.Item value="pro" /> Pro
  </label>
</RadioGroup.Root>
  • Root is a <fieldset role="radiogroup">: the legend names it, disabled reaches every radio natively, and it finds the form for resets. A render element must stay a <fieldset>.
  • Label is the <legend> and must be the Root's first child. Its function children receive the Root's state, so a group-level pending indicator goes there.
  • Item is the radio itself, a void element. There is no Indicator part: style the input with appearance: none and ::before, or use accent-color. Each Item's text label is your own <label>.

Props

RadioGroup.Root

PropTypeDefaultDescription
valuestring | null–Controlled checked value; null checks nothing. undefined means uncontrolled.
defaultValuestring | nullnullInitial value in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the radios' defaultChecked. An unchecked group is the HTML default, so leaving both out is fine.
valueChangeActionChangeAction<string | null, RadioGroupValueChangeDetails>–Runs in the Root's transition whenever the checked radio changes; sync, async or a Server Function. Rejections reach the nearest Error Boundary and roll the selection back. next is a string for every user interaction; it is null only with reason: "platform", when a form reset empties the group. Every arrow key press dispatches; the latest wins.
namestringuseId()The name shared by every radio: the form data key. Pass a real name inside a form you submit. Must be unique within the form.
requiredbooleanfalseMarks every radio required, which makes the group required; one checked radio satisfies it.
disabledbooleanfalseRenders <fieldset disabled>: every radio is disabled, unfocusable and skipped by the arrow keys.
formstring–The form attribute, applied to the fieldset and to every radio.
classNamestring | ((state: RadioGroupRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: RadioGroupRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: RadioGroupRootState) => ReactNode)–The Label and Items. The function form receives the optimistic value; must not suspend.
renderReactElement | ((props, state: RadioGroupRootState) => ReactNode)–Replace the default <fieldset> (element form) or take over rendering (function form).

RadioGroupRootState: { value, pending, disabled }, with value optimistic. The Root owns role. Other fieldset props pass through (aria-label for a group without a Label, for example).

RadioGroup.Label

PropTypeDefaultDescription
classNamestring | ((state: RadioGroupRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: RadioGroupRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: RadioGroupRootState) => ReactNode)–Content, or a function of the Root's state.
renderReactElement | ((props, state: RadioGroupRootState) => ReactNode)–Replace the default <legend> (element form) or take over rendering (function form).

RadioGroup.Item

PropTypeDefaultDescription
valuestring–Required. The value this radio checks, unique within the Root; submitted as the form value.
disabledbooleanfalseNative disabled: unfocusable, skipped by the arrow keys.
transitionViewTransitionClass–Forwarded to the Item's <ViewTransition default>; without it no boundary is rendered. Fires only on the commit that settles an action or rolls it back, never on the check itself.
classNamestring | ((state: RadioGroupItemState) => string | undefined)–A class name, or a function of the Item's state.
styleCSSProperties | ((state: RadioGroupItemState) => CSSProperties | undefined)–Inline style, or a function of the Item's state.
renderReactElement | ((props, state: RadioGroupItemState) => ReactNode)–Replace the default <input>. Must keep an <input>; the library sets type="radio".

RadioGroupItemState: { value, checked, pending, disabled }. checked is optimistic, pending is true on the radio the user is waiting on, and disabled includes Root disabled. The Item owns type, name, value, checked, defaultChecked, disabled, form, required and onChange; id and other input props pass through.

Data attributes and pseudo-classes

AttributeOnPseudo-classMeaning
data-pendingRoot–valueChangeAction is in flight.
data-pendingItem–The radio the in-flight dispatch is checking (the optimistic one).
data-checkedItem:checkedOptimistic checked state. Identical, because the DOM holds the optimistic value.
data-disabledRoot, Item:disabledDisabled by prop; on an Item, by its own prop or Root disabled.
–Item:required, :user-invalid, :focus-visiblePlatform only; no attribute.
–Item:indeterminateEvery radio of a group with nothing checked (platform).
.radio:checked::before { scale: 1; }   /* flips on click, reverts on rejection */
.radio[data-pending] { animation: pulse 1s infinite alternate; }
.option:has(.radio:checked) { font-weight: 600; }
.group[data-pending] { cursor: progress; }

Keyboard and ARIA

Everything here is the browser's; the library installs no key handler.

KeyBehaviourDispatches
Tab / Shift+TabEnters the group on the checked radio (the first, with nothing checked) and leaves it on the next press.no
SpaceChecks the focused radio.yes
ArrowDown / ArrowRightFocuses and checks the next enabled radio, wrapping (not in Safari). Mirrored under dir="rtl" (not in Safari).yes, once per press
ArrowUp / ArrowLeftPrevious enabled radio, wrapping (not in Safari). Mirrored under RTL (not in Safari).yes, once per press
EnterSubmits the form (implicit submission).no
  • No Home/End (not in the APG Radio Group pattern) and no orientation prop: native radios honour all four arrows whatever the layout.
  • Safari (WebKit) neither wraps at the ends of the group nor mirrors ArrowLeft / ArrowRight under RTL: Right and Down always go to the next radio in document order, Left and Up to the previous one, and a press past the end does nothing (and dispatches nothing). This is the platform's behaviour; the library deliberately adds no key handler to change it.
  • The checked state assistive technology reads is the optimistic one, so a new choice is announced at once. A rollback is silent; add an aria-live region if a slow action may fail.
  • The library never moves focus, not even on rollback: focus stays on the radio the user reached, and the checked radio is the group's tab stop again.
  • No aria-checked (forbidden on native inputs) and no tabindex (the browser does the roving).
المحاذاة (alignment)
value: "right"
Show code
RadioGroupRtl.tsx
"use client";
import { useState } from "react";
import { RadioGroup } from "@funstack/ui/radio-group";

const alignments = [
  { value: "right", label: "يمين (right)" },
  { value: "center", label: "وسط (center)" },
  { value: "left", label: "يسار (left)" },
];

// Inside dir="rtl" the browser mirrors the arrow keys: ArrowLeft moves to the next radio, which
// is visually to the left (Safari does not mirror). The library has no keyboard code of its own.
// Controlled with local state: the setState runs in the action's synchronous part, so it is a
// transition update.
export function RadioGroupRtl() {
  const [value, setValue] = useState<string | null>("right");
  return (
    <>
      <div dir="rtl">
        <RadioGroup.Root
          className="demo-radio-group demo-radio-inline"
          value={value}
          valueChangeAction={(next) => {
            setValue(next);
          }}
        >
          <RadioGroup.Label>المحاذاة (alignment)</RadioGroup.Label>
          {alignments.map((option) => (
            <label key={option.value} className="demo-field">
              <RadioGroup.Item value={option.value} className="demo-radio" />
              {option.label}
            </label>
          ))}
        </RadioGroup.Root>
      </div>
      <output className="demo-readout">
        value: <code>{JSON.stringify(value)}</code>
      </output>
    </>
  );
}
radio-group.css
/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
   (appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
   optimistic value. */
.demo-radio-group {
  display: grid;
  gap: 0.4rem;
  justify-items: start;
  margin: 0;
  padding: 0.5rem 1rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

.demo-radio-group legend {
  padding-inline: 0.3rem;
  font-weight: 600;
  color: var(--color-text);
}

.demo-radio-group[data-pending] {
  cursor: progress;
}

.demo-radio-inline {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
}

.demo-radio {
  appearance: none;
  flex: none;
  margin: 0;
  inline-size: 1.25em;
  block-size: 1.25em;
  border: 2px solid var(--color-text-secondary);
  border-radius: 50%;
  display: inline-grid;
  place-content: center;
  cursor: inherit;
  transition: border-color 120ms;
}

.demo-radio::before {
  content: "";
  inline-size: 0.6em;
  block-size: 0.6em;
  border-radius: 50%;
  background: var(--color-primary);
  scale: 0;
  transition: scale 120ms;
}

.demo-radio:checked {
  border-color: var(--color-primary);
}

/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
  scale: 1;
}

.demo-radio:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.demo-radio:user-invalid {
  border-color: #dc2626;
}

.demo-field:has(.demo-radio:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
  display: none;
}

.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
  display: inline-block;
}

.demo-radio-saving {
  font-weight: normal;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .demo-radio,
  .demo-radio::before {
    transition: none;
  }
}

Async walkthrough

  1. Click Dark (or arrow to it): the browser checks it and unchecks the old radio; the group dispatches valueChangeAction("dark", { reason: "activate" }) in the Root's transition. The Root and the Dark radio get data-pending.
  2. Resolve: the value is committed (uncontrolled) and the attributes go.
  3. Reject: the optimistic value reverts, React writes checked back to the old radio, and the error reaches the nearest Error Boundary.
  4. Arrow keys pressed quickly dispatch once per press; only the latest dispatch commits. For a preference that needs no wait, do not return the promise ((next) => { void save(next); }), or leave the value to the form submit.

Why the DOM shows the optimistic value

After every event react-dom restores controlled radios to their props. Only the synchronous optimistic state has rendered by then, so radios driven by committed state would snap back right after the click. Rendering the optimistic value is a correctness requirement for native inputs. details.event.preventDefault() is therefore not a veto; a controlled parent vetoes by not adopting the value.

Forms

T-shirt size
ordered: null
valueChangeAction calls, latest first: (none yet)
Show code
RadioGroupForm.tsx
"use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { RadioGroup } from "@funstack/ui/radio-group";
import type { RadioGroupValueChangeDetails } from "@funstack/ui/radio-group";
import { fakeSave } from "./fake-api.js";

const sizes = [
  { value: "s", label: "Small" },
  { value: "m", label: "Medium" },
  { value: "l", label: "Large" },
  { value: "xl", label: "Extra large (sold out)" },
];

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" className="demo-button demo-primary">
      {pending && <span className="demo-spinner" aria-hidden />}
      Order
    </button>
  );
}

// The radios post `size=…` with the form (a real `name`), and `required` blocks the submit until
// one is checked. After the form action succeeds React resets the form; the ordered size is the
// new `defaultValue`, so the reset returns to it. The Reset button returns to the latest default
// too. `valueChangeAction` here only records what the group was told: user changes arrive with
// reason "activate", resets with reason "platform" (`null` when the reset empties the group).
export function RadioGroupForm() {
  const [ordered, setOrdered] = useState<string | null>(null);
  const [log, setLog] = useState<string[]>([]);
  const record = (next: string | null, { reason, event }: RadioGroupValueChangeDetails) => {
    const entry = `${JSON.stringify(next)} (${reason}, ${event?.type ?? "no event"})`;
    setLog((prev) => [entry, ...prev].slice(0, 4));
  };
  return (
    <form
      className="demo-stack"
      action={async (formData) => {
        await fakeSave("the order");
        const size = formData.get("size");
        startTransition(() => setOrdered(typeof size === "string" ? size : null));
      }}
    >
      <RadioGroup.Root
        className="demo-radio-group"
        name="size"
        required
        defaultValue={ordered}
        valueChangeAction={record}
      >
        <RadioGroup.Label>T-shirt size</RadioGroup.Label>
        {sizes.map((size) => (
          <label key={size.value} className="demo-field">
            <RadioGroup.Item
              value={size.value}
              className="demo-radio"
              disabled={size.value === "xl"}
            />
            {size.label}
          </label>
        ))}
      </RadioGroup.Root>
      <div className="demo-row">
        <SubmitButton />
        <button type="reset" className="demo-button">
          Reset
        </button>
      </div>
      <output className="demo-readout">
        ordered: <code>{JSON.stringify(ordered)}</code>
        <br />
        valueChangeAction calls, latest first: <code>{log.join(" · ") || "(none yet)"}</code>
      </output>
    </form>
  );
}
radio-group.css
/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
   (appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
   optimistic value. */
.demo-radio-group {
  display: grid;
  gap: 0.4rem;
  justify-items: start;
  margin: 0;
  padding: 0.5rem 1rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

.demo-radio-group legend {
  padding-inline: 0.3rem;
  font-weight: 600;
  color: var(--color-text);
}

.demo-radio-group[data-pending] {
  cursor: progress;
}

.demo-radio-inline {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
}

.demo-radio {
  appearance: none;
  flex: none;
  margin: 0;
  inline-size: 1.25em;
  block-size: 1.25em;
  border: 2px solid var(--color-text-secondary);
  border-radius: 50%;
  display: inline-grid;
  place-content: center;
  cursor: inherit;
  transition: border-color 120ms;
}

.demo-radio::before {
  content: "";
  inline-size: 0.6em;
  block-size: 0.6em;
  border-radius: 50%;
  background: var(--color-primary);
  scale: 0;
  transition: scale 120ms;
}

.demo-radio:checked {
  border-color: var(--color-primary);
}

/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
  scale: 1;
}

.demo-radio:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.demo-radio:user-invalid {
  border-color: #dc2626;
}

.demo-field:has(.demo-radio:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
  display: none;
}

.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
  display: inline-block;
}

.demo-radio-saving {
  font-weight: normal;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .demo-radio,
  .demo-radio::before {
    transition: none;
  }
}
  • With a real name the group posts the checked value; required blocks the submit until a radio is checked and every radio matches :user-invalid after the attempt.
  • Form reset (the Reset button, form.reset(), or React's automatic reset after the action succeeds) is one dispatch for the whole group, with reason: "platform" and the reset event. Uncontrolled, the target is the latest defaultValue (here the ordered size). Controlled, the mount-time value is offered to the parent, which adopts it or not. A disabled group is reset too, since the form's author reset it.
  • Clicks before hydration (and Firefox restoring form state on reload) work natively; on hydration the Root finds the checked radio differs from its state and dispatches once, with no event. Items that hydrate later, inside a Suspense boundary, trigger one more check.

Server Functions and forms

// Autosave: a per-group action with data-pending, passed directly from a Server Component.
<RadioGroup.Root defaultValue={user.plan} valueChangeAction={setPlan}>...</RadioGroup.Root>

// Native submission: no action on the group; pending is the form's.
<form action={placeOrder}>
  <RadioGroup.Root name="size" required>...</RadioGroup.Root>
  <SubmitButton />
</form>

The two channels are independent and may be combined. The group never reads useFormStatus. An action-less group is a working native radio group before hydration and with JavaScript off.