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.
"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: 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;
}
}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.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | – | Controlled checked state. undefined means uncontrolled. |
defaultChecked | boolean | false | Initial checked state in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the input's defaultChecked. |
checkedChangeAction | ChangeAction<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). |
disabled | boolean | false | Native disabled; never dispatches. |
value | string | "on" | Submitted with the form when checked (platform default). |
transition | ViewTransitionClass | – | 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. |
className | string | ((state: SwitchState) => string | undefined) | – | A class name, or a function of the state. |
style | CSSProperties | ((state: SwitchState) => CSSProperties | undefined) | – | Inline style, or a function of the state. |
render | ReactElement | ((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.
| Attribute | Pseudo-class | Meaning |
|---|---|---|
data-checked | :checked | Optimistic checked state. Identical, because the DOM holds the optimistic value. |
data-pending | – | checkedChangeAction is in flight. |
data-disabled | :disabled | The disabled prop. Only :disabled also sees <fieldset disabled>. |
| – | :required, :user-invalid, :focus-visible | Platform only; no attribute. |
Data attributes are still emitted so that a wrapper can use them; on a wrapper, :has(:checked) works without JavaScript.
| Interaction | Behaviour |
|---|---|
| Space | Toggles (native). |
| Click, tap, label click | Toggles (native). |
| Enter | Does not toggle (native); may submit the form. |
| Tab | Focusable unless disabled. |
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.checkedChangeAction(next, { reason: "activate" }) in its transition; data-checked and data-pending update in the same frame.checked decides. data-pending goes.checked back to the input, and the error reaches the nearest Error Boundary (the "Beta features" row above)."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: 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 library owns checked, but the browser can still change it on its own. Both cases reach checkedChangeAction with reason: "platform":
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.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 | |
|---|---|---|
| Runs | on every change, in the Switch's transition | on submit, in the form's |
| Pending | data-pending on the Switch | useFormStatus() in the form; the Switch shows nothing |
| Receives | (next, details), details.event undefined | FormData |
| Rollback | optimistic value reverts on rejection | n/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.