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
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Toggle

A two-state button (WAI-ARIA Button pattern, toggle variant). The pressed state flips optimistically and the button carries data-pending while pressedChangeAction runs.

Show code
ToggleFavorite.tsx
"use client";
import { Toggle } from "@funstack/ui/toggle";
import { fakeSave } from "./fake-api.js";

// Uncontrolled Toggle with an async action. The star flips at once (optimistic), the button
// carries data-pending for the 800 ms the save takes, and a second click while pending wins.
export function ToggleFavorite() {
  return (
    <Toggle className="demo-toggle" pressedChangeAction={(next) => fakeSave(`favorite=${next}`)}>
      {({ pressed, pending }) => (
        <>
          {pending ? (
            <span className="demo-spinner" aria-hidden />
          ) : (
            <span aria-hidden>{pressed ? "★" : "☆"}</span>
          )}
          Favorite
        </>
      )}
    </Toggle>
  );
}
toggle.css
/* Toggle: data attributes carry library state; :hover / :focus-visible come from the platform. */
.demo-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.4em 0.9em;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
  cursor: pointer;
  /* The optimistic flip is a sync commit, so it animates with a plain CSS transition. */
  transition:
    background-color 120ms,
    color 120ms,
    border-color 120ms;
}

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

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

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

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

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

Anatomy

import { Toggle } from "@funstack/ui/toggle";

<Toggle pressedChangeAction={setFavorite}>Favorite</Toggle>

One plain component, no parts. It renders <button type="button" aria-pressed>.

Props

PropTypeDefaultDescription
pressedboolean–Controlled pressed state. undefined means uncontrolled.
defaultPressedbooleanfalseInitial pressed state in uncontrolled mode.
pressedChangeActionChangeAction<boolean, TogglePressedChangeDetails>–Called with the next pressed state on every user-initiated change. Runs inside the Toggle's transition; may be sync or async. Rejections reach the nearest Error Boundary. details carries only event (the native click event; keyboard activation also arrives as a click).
disabledbooleanfalseRenders a native disabled button; never dispatches.
transitionViewTransitionClass–Forwarded to the Toggle's <ViewTransition default>. Animates the commit that ends an action (and rollbacks), not the optimistic flip.
classNamestring | ((state: ToggleState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ToggleState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ToggleState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((props, state: ToggleState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

Every other <button> prop passes through, except onClick, type and aria-pressed, which the Toggle owns. ToggleState is { pressed, pending, disabled }.

Data attributes

AttributeMeaning
data-pressedOptimistically pressed.
aria-pressed"true" or "false", optimistic.
data-pendingpressedChangeAction is in flight.
data-disabledThe disabled prop (also matches :disabled).

Keyboard and ARIA

InteractionBehaviour
Space, Enter, clickToggles (native button activation).
TabFocusable unless disabled.
  • aria-pressed is always "true" or "false", never "mixed", and follows the optimistic state.
  • Keep the accessible name stable: the label should not change with the pressed state (swap an icon, not the text).
  • The button is not disabled while pending. A second click while pending reverses the first, and the latest click wins.

Async walkthrough

  1. Click: aria-pressed and data-pressed flip at once, data-pending appears, pressedChangeAction(true, details) runs.
  2. Resolve: in uncontrolled mode the value is committed; data-pending goes. In controlled mode the parent's pressed decides.
  3. Reject: the pressed state rolls back and the error reaches the nearest Error Boundary.
  4. Click again while pending: the next value is the opposite of the optimistic value, so the second click undoes the first. Only the latest dispatch commits.
Show code
ToggleErrors.tsx
"use client";
import { startTransition, useRef, useState } from "react";
import { Toggle } from "@funstack/ui/toggle";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";

// Two ways to handle a failing action.
export function ToggleErrors() {
  return (
    <div className="demo-row">
      <ControlledWithInlineError />
      <DemoErrorBoundary>
        <Toggle
          className="demo-toggle"
          pressedChangeAction={(next) => fakeSave(`notify=${next}`, { fail: true })}
        >
          {({ pending }) => (
            <>{pending && <span className="demo-spinner" aria-hidden />}Notify me</>
          )}
        </Toggle>
      </DemoErrorBoundary>
    </div>
  );
}

// Controlled, "update on success": the parent adopts the value only after the save succeeds.
// Every second save fails; the error is caught inside the action, so the Toggle snaps back.
function ControlledWithInlineError() {
  const [pinned, setPinned] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const saves = useRef(0);
  return (
    <span>
      <Toggle
        className="demo-toggle"
        pressed={pinned}
        pressedChangeAction={async (next) => {
          setError(null); // sync prefix: already a Transition update
          try {
            await fakeSave(`pinned=${next}`, { fail: ++saves.current % 2 === 0 });
            // After an await, wrap state updates in startTransition yourself.
            startTransition(() => setPinned(next));
          } catch (e) {
            startTransition(() => setError(e instanceof Error ? e.message : String(e)));
          }
        }}
      >
        {({ pending }) => <>{pending && <span className="demo-spinner" aria-hidden />}Pin</>}
      </Toggle>
      {error && <span className="demo-inline-error"> {error}</span>}
    </span>
  );
}

The left Toggle catches its errors inline; the right one lets them reach a boundary.

Recipes

Controlled, local state

const [muted, setMuted] = useState(false);

<Toggle pressed={muted} pressedChangeAction={(next) => { setMuted(next); }}>
  Mute
</Toggle>

A Server Function, from a Server Component

<Toggle defaultPressed={post.favorited} pressedChangeAction={setFavorite.bind(null, post.id)}>
  Favorite
</Toggle>

Disabling while pending

The library does not do it (native disabled would drop keyboard focus). Use .toggle[data-pending] { pointer-events: none }, or add aria-busy yourself.