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 ↗

Switch

An on/off switch (WAI-ARIA Switch pattern, HTML checkbox variant). It is a real <input type="checkbox" role="switch">: the DOM checked is the optimistic state, the input carries data-pending while checkedChangeAction runs, and it takes part in forms like any checkbox.

Show code
SwitchSettings.tsx
"use client";
import { Switch } from "@funstack/ui/switch";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";

// Autosave on change. Each Switch is uncontrolled with an async action: the input flips at once
// (its DOM `checked` is the optimistic value), carries data-pending for the 800 ms the save
// takes, then keeps the value. The second save always fails: the switch flips back and the
// error reaches the Error Boundary around it.
export function SwitchSettings() {
  return (
    <div className="demo-stack">
      <label className="demo-field">
        <Switch
          className="demo-switch"
          defaultChecked
          checkedChangeAction={(next) => fakeSave(`notifications=${next}`)}
        />
        Email notifications
      </label>
      <DemoErrorBoundary>
        <label className="demo-field">
          <Switch
            className="demo-switch"
            checkedChangeAction={(next) => fakeSave(`beta=${next}`, { fail: true })}
          />
          Beta features (the save always fails)
        </label>
      </DemoErrorBoundary>
    </div>
  );
}
switch.css
/* Switch: the input is the track, ::before is the thumb. `:checked` and `[data-checked]` agree,
   because the DOM `checked` is the optimistic state. */
.demo-switch {
  appearance: none;
  flex: none;
  margin: 0;
  inline-size: 2.5em;
  block-size: 1.5em;
  border-radius: 1em;
  background: var(--color-border);
  cursor: inherit;
  transition: background-color 120ms;
}

.demo-switch::before {
  content: "";
  display: block;
  inline-size: 1.2em;
  block-size: 1.2em;
  margin: 0.15em;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
  transition: translate 120ms;
}

/* The flip is a sync commit: a plain CSS transition animates it, and the rollback too. */
.demo-switch:checked {
  background: var(--color-primary);
}

.demo-switch:checked::before {
  translate: 1em 0;
}

.demo-switch[data-pending]::before {
  background: radial-gradient(circle, var(--color-primary) 0 25%, #fff 30%);
}

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

.demo-switch:disabled {
  opacity: 0.5;
}

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

Anatomy

import { Switch } from "@funstack/ui/switch";

<label>
  <Switch checkedChangeAction={saveNotifications} />
  Email notifications
</label>

One plain component, no parts. It renders a single void element, so there is no Thumb: the input is the track and ::before the thumb (appearance: none), or hide the input with a clip and style a sibling through label:has(:checked). There is no Label part either: your <label> (wrapping, or htmlFor with an id) names it and forwards clicks, before hydration too.

Props

PropTypeDefaultDescription
checkedboolean–Controlled checked state. undefined means uncontrolled.
defaultCheckedbooleanfalseInitial checked state in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the input's defaultChecked.
checkedChangeActionChangeAction<boolean, SwitchCheckedChangeDetails>–Called with the next checked state on every change, inside the Switch's transition. Sync, async or a Server Function; rejections reach the nearest Error Boundary. details.reason is "activate" for the user (event is the native click, for Space and label clicks too) or "platform" for a form reset (event is the reset event) or a change found at hydration (event is undefined).
disabledbooleanfalseNative disabled; never dispatches.
valuestring"on"Submitted with the form when checked (platform default).
transitionViewTransitionClass–Forwarded to the Switch's <ViewTransition default>; without it no boundary is rendered. Animates the commit that ends an action, not the flip; animate the flip with CSS on :checked.
classNamestring | ((state: SwitchState) => string | undefined)–A class name, or a function of the state.
styleCSSProperties | ((state: SwitchState) => CSSProperties | undefined)–Inline style, or a function of the state.
renderReactElement | ((props, state: SwitchState) => ReactNode)–Replace the default <input> or take over rendering. Must keep an <input type="checkbox"> (development warning otherwise); the function form may render siblings next to it.

SwitchState is { checked, pending, disabled }, with checked optimistic. Every other <input> prop passes through (name, required, form, id, autoFocus, ...). The Switch owns type, role, checked, defaultChecked, disabled and onChange, and removes aria-checked. A render element's own onChange or onClick runs first.

Data attributes and pseudo-classes

AttributePseudo-classMeaning
data-checked:checkedOptimistic checked state. Identical, because the DOM holds the optimistic value.
data-pending–checkedChangeAction is in flight.
data-disabled:disabledThe disabled prop. Only :disabled also sees <fieldset disabled>.
–:required, :user-invalid, :focus-visiblePlatform only; no attribute.

Data attributes are still emitted so that a wrapper can use them; on a wrapper, :has(:checked) works without JavaScript.

Keyboard and ARIA

InteractionBehaviour
SpaceToggles (native).
Click, tap, label clickToggles (native).
EnterDoes not toggle (native); may submit the form.
TabFocusable unless disabled.
  • The library installs no key handler. Every activation reaches it as a native click.
  • role="switch" on a checkbox input is allowed by ARIA in HTML. aria-checked is never set (it is forbidden on native inputs): the native checked state is the ARIA state, and since it is the optimistic value, assistive technology hears the flip at once.
  • Keep the label stable: the state is already conveyed.
  • The input is not disabled while pending. A second click reverses the first; the latest wins.

Async walkthrough

  1. Click: the browser flips the input; the Switch dispatches checkedChangeAction(next, { reason: "activate" }) in its transition; data-checked and data-pending update in the same frame.
  2. Resolve: uncontrolled, the value is committed; controlled, the parent's checked decides. data-pending goes.
  3. Reject: the optimistic value reverts, React writes the old checked back to the input, and the error reaches the nearest Error Boundary (the "Beta features" row above).

Why the DOM shows the optimistic value

This is a correctness requirement, not a style choice. After every event react-dom restores a controlled input to its current props. Only the synchronous optimistic state has rendered by then, so an input driven by committed (transition) state would snap back right after the click and stay wrong until the action finished.

preventDefault() is not a veto

Calling details.event.preventDefault() inside the action makes the browser undo the click after React has rendered it, so the input disagrees with data-checked while the action is pending (development warning). To veto, use controlled mode and do not adopt the value.

Forms

last submitted: (nothing yet)
Show code
SwitchForm.tsx
"use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { Switch } from "@funstack/ui/switch";
import { fakeSave } from "./fake-api.js";

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

// No change actions: the switches are native checkboxes that post `name=value` with the form.
// After the form action succeeds React resets the form; the saved values are the new
// `defaultChecked`, so the reset lands on what was saved. The Reset button goes through the same
// path (the browser restores `defaultChecked`; the Switch follows with reason "platform").
export function SwitchForm() {
  const [saved, setSaved] = useState({ newsletter: false, digest: true });
  const [submitted, setSubmitted] = useState("(nothing yet)");
  return (
    <form
      className="demo-stack"
      action={async (formData) => {
        await fakeSave("the settings");
        const next = {
          newsletter: formData.get("newsletter") === "on",
          digest: formData.get("digest") === "weekly",
        };
        startTransition(() => {
          setSaved(next);
          setSubmitted(JSON.stringify([...formData]));
        });
      }}
    >
      <label className="demo-field">
        <Switch className="demo-switch" name="newsletter" defaultChecked={saved.newsletter} />
        Newsletter
      </label>
      <label className="demo-field">
        <Switch
          className="demo-switch"
          name="digest"
          value="weekly"
          defaultChecked={saved.digest}
        />
        Weekly digest
      </label>
      <div className="demo-row">
        <SubmitButton />
        <button type="reset" className="demo-button">
          Reset
        </button>
      </div>
      <output className="demo-readout">
        last submitted: <code>{submitted}</code>
      </output>
    </form>
  );
}
switch.css
/* Switch: the input is the track, ::before is the thumb. `:checked` and `[data-checked]` agree,
   because the DOM `checked` is the optimistic state. */
.demo-switch {
  appearance: none;
  flex: none;
  margin: 0;
  inline-size: 2.5em;
  block-size: 1.5em;
  border-radius: 1em;
  background: var(--color-border);
  cursor: inherit;
  transition: background-color 120ms;
}

.demo-switch::before {
  content: "";
  display: block;
  inline-size: 1.2em;
  block-size: 1.2em;
  margin: 0.15em;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
  transition: translate 120ms;
}

/* The flip is a sync commit: a plain CSS transition animates it, and the rollback too. */
.demo-switch:checked {
  background: var(--color-primary);
}

.demo-switch:checked::before {
  translate: 1em 0;
}

.demo-switch[data-pending]::before {
  background: radial-gradient(circle, var(--color-primary) 0 25%, #fff 30%);
}

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

.demo-switch:disabled {
  opacity: 0.5;
}

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

A Switch with a name posts name=value when checked, exactly like a checkbox. No change action is needed for that.

The platform paths

The library owns checked, but the browser can still change it on its own. Both cases reach checkedChangeAction with reason: "platform":

  • Form reset: a reset button, form.reset(), or React's automatic reset after a successful <form action>. Uncontrolled, the reset target is the latest defaultChecked (so a form re-rendered with saved data resets to it, as in the example). Controlled, the browser resets to the mount-time value and the change is offered to the parent, which adopts it or not; the input then follows the parent.
  • Clicks before hydration (and Firefox restoring form state on reload): the server-rendered input works natively; on hydration the Switch finds the DOM differs from its state and dispatches once, with no event.

Server Functions and forms

Two independent channels. A Server Component can use either, or both on the same Switch:

// Autosave on change: a per-switch action with data-pending.
<Switch defaultChecked={user.notifications} checkedChangeAction={setNotifications} />

// Native submission: posted with the form, pending is the form's.
<form action={saveSettings}>
  <Switch name="notifications" defaultChecked={user.notifications} />
  <SubmitButton />
</form>
checkedChangeAction={serverFn}<form action={serverFn}> + name
Runson every change, in the Switch's transitionon submit, in the form's
Pendingdata-pending on the SwitchuseFormStatus() in the form; the Switch shows nothing
Receives(next, details), details.event undefinedFormData
Rollbackoptimistic value reverts on rejectionn/a

The Switch never reads useFormStatus: that would mark every control pending during a submit. To dim controls while a form submits, style form:has(...) or read useFormStatus in your own className function. "Submit on change" is checkedChangeAction={() => formRef.current?.requestSubmit()}.

A Switch with no action and no checked is a working native input before hydration and with JavaScript off.