A two-state button (WAI-ARIA Button pattern, toggle variant). The pressed state flips optimistically and the button carries data-pending while pressedChangeAction runs.
"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: 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;
}
}import { Toggle } from "@funstack/ui/toggle";
<Toggle pressedChangeAction={setFavorite}>Favorite</Toggle>One plain component, no parts. It renders <button type="button" aria-pressed>.
| Prop | Type | Default | Description |
|---|---|---|---|
pressed | boolean | – | Controlled pressed state. undefined means uncontrolled. |
defaultPressed | boolean | false | Initial pressed state in uncontrolled mode. |
pressedChangeAction | ChangeAction<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). |
disabled | boolean | false | Renders a native disabled button; never dispatches. |
transition | ViewTransitionClass | – | Forwarded to the Toggle's <ViewTransition default>. Animates the commit that ends an action (and rollbacks), not the optimistic flip. |
className | string | ((state: ToggleState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToggleState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToggleState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((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 }.
| Attribute | Meaning |
|---|---|
data-pressed | Optimistically pressed. |
aria-pressed | "true" or "false", optimistic. |
data-pending | pressedChangeAction is in flight. |
data-disabled | The disabled prop (also matches :disabled). |
| Interaction | Behaviour |
|---|---|
| Space, Enter, click | Toggles (native button activation). |
| Tab | Focusable unless disabled. |
aria-pressed is always "true" or "false", never "mixed", and follows the optimistic state.aria-pressed and data-pressed flip at once, data-pending appears, pressedChangeAction(true, details) runs.data-pending goes. In controlled mode the parent's pressed decides."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.
const [muted, setMuted] = useState(false);
<Toggle pressed={muted} pressedChangeAction={(next) => { setMuted(next); }}>
Mute
</Toggle><Toggle defaultPressed={post.favorited} pressedChangeAction={setFavorite.bind(null, post.id)}>
Favorite
</Toggle>