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
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

ToggleGroup

A set of toggle buttons that share one value: any number pressed by default, at most one with exclusive. Every Item is the Toggle element (<button aria-pressed>); the group is one Tab stop with arrow keys between the Items, and inside a Toolbar its Items join the toolbar's sequence.

Show code
ToggleGroupStyles.tsx
"use client";
import { useState } from "react";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";

// Multiple mode, uncontrolled, with an async save. Each Item flips at once and shows a spinner
// (data-pending) until its save settles. With "fail" on, the save rejects: the Item flips back
// and the error reaches the boundary.
export function ToggleGroupStyles() {
  const [fail, setFail] = useState(false);
  return (
    <div className="demo-stack">
      <label>
        <input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} /> Make
        the save fail
      </label>
      <DemoErrorBoundary>
        <ToggleGroup.Root
          aria-label="Text style"
          className="demo-toggle-group"
          defaultValue={["bold"]}
          valueChangeAction={(next, { added, removed }) =>
            fakeSave(`styles (added ${added}, removed ${removed}) → ${next}`, { fail })
          }
        >
          {["Bold", "Italic", "Underline"].map((label) => (
            <ToggleGroup.Item key={label} value={label.toLowerCase()} className="demo-toggle-item">
              {({ pending }) => (
                <>
                  {pending && <span className="demo-spinner" aria-hidden />}
                  {label}
                </>
              )}
            </ToggleGroup.Item>
          ))}
        </ToggleGroup.Root>
      </DemoErrorBoundary>
    </div>
  );
}
toggle-group.css
/* ToggleGroup: data attributes carry library state; :focus-visible comes from the platform. */
.demo-toggle-group {
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

.demo-toggle-group[data-orientation="vertical"] {
  flex-direction: column;
}

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

.demo-toggle-item {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.3em 0.8em;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--color-text);
  font: inherit;
  cursor: pointer;
  /* The optimistic flip is a sync commit: a plain CSS transition animates it. */
  transition:
    background-color 120ms,
    color 120ms;
}

.demo-toggle-item[data-pressed] {
  background: var(--color-primary);
  color: var(--color-on-primary);
}

.demo-toggle-item[data-pending] {
  cursor: progress;
}

.demo-toggle-item[data-disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

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

@media (prefers-reduced-motion: reduce) {
  .demo-toggle-item {
    transition: none;
  }
}

Each Item flips the moment it is clicked and carries data-pending until its save settles; the group carries it too. A rejected save flips the Item back and reaches the Error Boundary around the group. Name the group with aria-label or aria-labelledby: it is what tells screen-reader users the buttons belong together.

Exclusive mode

Alignment: left

Layout: grid

Show code
ToggleGroupAlignment.tsx
"use client";
import { startTransition, useState } from "react";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { fakeSave } from "./fake-api.js";

// Exclusive mode. The first group allows "nothing pressed" (press Left again to release it);
// the second is a view switcher with deselectable={false}: pressing the pressed Item does
// nothing. Controlled, "update on success": the setState after the await is a transition.
export function ToggleGroupAlignment() {
  const [align, setAlign] = useState<string | null>("left");
  const [layout, setLayout] = useState<string | null>("grid");
  return (
    <div className="demo-stack">
      <ToggleGroup.Root
        exclusive
        aria-label="Text alignment"
        className="demo-toggle-group"
        value={align}
        valueChangeAction={async (next) => {
          await fakeSave(`alignment=${next}`);
          startTransition(() => setAlign(next));
        }}
      >
        <ToggleGroup.Item value="left" className="demo-toggle-item">
          Left
        </ToggleGroup.Item>
        <ToggleGroup.Item value="center" className="demo-toggle-item">
          Center
        </ToggleGroup.Item>
        <ToggleGroup.Item value="right" className="demo-toggle-item">
          Right
        </ToggleGroup.Item>
      </ToggleGroup.Root>
      <p>
        Alignment: <output>{align ?? "none"}</output>
      </p>
      <ToggleGroup.Root
        exclusive
        deselectable={false}
        aria-label="Layout"
        className="demo-toggle-group"
        value={layout}
        valueChangeAction={(next) => {
          setLayout(next);
        }}
      >
        <ToggleGroup.Item value="grid" className="demo-toggle-item">
          Grid
        </ToggleGroup.Item>
        <ToggleGroup.Item value="list" className="demo-toggle-item">
          List
        </ToggleGroup.Item>
      </ToggleGroup.Root>
      <p>
        Layout: <output>{layout}</output>
      </p>
    </div>
  );
}

With exclusive, value is string | null. Pressing the pressed Item releases it (next === null); a view switcher that must always show one value passes deselectable={false}, and pressing the pressed Item then dispatches nothing.

When to use RadioGroup instead

The Items stay aria-pressed buttons in both modes, so a screen reader does not announce "1 of 3" or mutual exclusivity. If the choice is a form value, must always have a value, or should be announced as one choice among several, use a RadioGroup styled as a segmented control: native radios, arrows that select, name and required.

Controlled, saved on the server

next is the whole set; details.added and details.removed say what changed. When you update after your own await, apply the diff with a functional update: two overlapping saves that settle out of order then cannot lose a change.

const [styles, setStyles] = useState<string[]>(doc.styles);

<ToggleGroup.Root
  aria-label="Text style"
  value={styles}
  valueChangeAction={async (_next, { added, removed }) => {
    await api.patchStyles(doc.id, { added, removed });
    startTransition(() =>
      setStyles((prev) => [
        ...prev.filter((v) => !removed.includes(v)),
        ...added.filter((v) => !prev.includes(v)),
      ]),
    );
  }}
>
  …
</ToggleGroup.Root>

A slow valueChangeAction holds every other transition in the app until it settles; for a fire-and-forget save, call void save(next) without awaiting it.

Anatomy

import { ToggleGroup } from "@funstack/ui/toggle-group";

<ToggleGroup.Root aria-label="Text style" defaultValue={["bold"]} valueChangeAction={save}>
  <ToggleGroup.Item value="bold">Bold</ToggleGroup.Item>
  <ToggleGroup.Item value="italic">Italic</ToggleGroup.Item>
</ToggleGroup.Root>
PartElementRole
ToggleGroup.Root<div role="group">Owns the value, the transition and the roving focus.
ToggleGroup.Item<button type="button" aria-pressed>One toggle; its pressed state is the Root's.

Props

ToggleGroup.Root

The props are discriminated on exclusive: without it (or with exclusive={false}) the value is a string[]; with exclusive it is string | null, and deselectable becomes available. The mode is fixed when the group mounts.

PropTypeDefaultDescription
exclusivebooleanfalseAt most one Item pressed; value becomes string | null.
valuestring[] | (exclusive) string | null–Controlled pressed values. [] / null presses nothing; undefined means uncontrolled. Order is not significant.
defaultValuestring[] | (exclusive) string | null[] / nullInitial pressed values in uncontrolled mode.
valueChangeActionChangeAction<string[] | (exclusive) string | null, ToggleGroupValueChangeDetails>–Runs inside the Root's transition on every change. next is a fresh array in document order (exclusive: the value or null); details is { added, removed } plus the virtual event. Rejections roll the change back and reach the nearest Error Boundary.
deselectablebooleantrueExclusive only. false: pressing the pressed Item does nothing, so the action never receives null.
orientation"horizontal" | "vertical""horizontal"Axis of the arrow keys and data-orientation. Ignored inside a Toolbar (development warning).
loopbooleantrueArrows wrap from the last Item to the first. Ignored inside a Toolbar.
disabledbooleanfalseDisables every Item (native disabled).
experimental_focusgroupbooleanfalseRenders focusgroup="toolbar … nomemory" and focusgroupstart on the first pressed Item; where the browser implements it (Chromium 150+) the browser moves focus. Ignored inside a Toolbar. See Experimental features.
classNamestring | ((state: ToggleGroupRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ToggleGroupRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ToggleGroupRootState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: ToggleGroupRootState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

ToggleGroupRootState is { value, pending, disabled, orientation }; value is the optimistic set as an array in document order (exclusive mode: [value] or []). Every other <div> prop passes through, except role and the handlers of the roving focus (onKeyDown, onFocus, onBlur, onPointerDown), which are not props.

ToggleGroup.Item

PropTypeDefaultDescription
valuestring–The value this Item presses. Unique within the group.
disabledbooleanfalseNative disabled; never dispatches; skipped by the arrow keys.
transitionViewTransitionClass–Forwarded to the Item's <ViewTransition default>. Animates the commit that ends the group's action, not the optimistic flip.
classNamestring | ((state: ToggleGroupItemState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ToggleGroupItemState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ToggleGroupItemState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((props, state: ToggleGroupItemState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

ToggleGroupItemState is { pressed, pending, disabled, orientation }. onClick, type, aria-pressed and tabIndex are the library's: the roving focus writes tabindex itself.

Data attributes

AttributeOnMeaning
data-orientationRoot, ItemThe effective orientation (the Toolbar's inside one).
data-pendingRootAn action dispatched through the group is in flight.
data-pendingItemIn flight and this Item's optimistic state differs from its committed one.
data-pressedItemOptimistically pressed (agrees with aria-pressed).
data-disabledRoot, ItemDisabled (the Item's own or the Root's).

Keyboard and ARIA

KeyHorizontalVerticalEffect
ArrowRight / ArrowLeftNext / previous enabled Item (swapped under direction: rtl)IgnoredMoves focus only; wraps if loop. Never presses.
ArrowDown / ArrowUpIgnoredNext / previous enabled ItemSame.
Home / EndFirst / last enabled ItemSameSame.
Space / EnterToggles the focused Item (native button activation).
Tab / Shift+TabEnters on the first pressed enabled Item, else the first enabled one; leaves with the next press.
  • The Root is role="group" with no aria-orientation (ARIA does not allow it on a group); orientation is data-orientation only.
  • aria-pressed is always "true" or "false" and follows the optimistic state. Focus never changes it.
  • Disabled Items are natively disabled and leave the arrow-key sequence. A rejected action rolls the pressed state back; focus stays where it is.
  • Before hydration every Item is a Tab stop; the single Tab stop is set up by the first effect. The server HTML carries no tabindex.

Styling

.group[data-pending] { cursor: progress; }
.item[data-pressed] { background: var(--accent); color: white; }
.item[data-pending]::after { content: ""; /* spinner */ }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

The optimistic flip is a synchronous commit: animate it with a CSS transition on [data-pressed]. View transition types are fsui-toggle-group, plus fsui-toggle-group-press and / or fsui-toggle-group-release.