Base components
Toggle
An on and off switch with an optional label and supporting text, where the thumb is thrown to the other side with a small overshoot and the track eases into the brand color.
Live preview
Loading preview
A settings list with "Email notifications", "Push notifications" (with supporting text) and a disabled "SMS notifications", in Default and Slim, both sizes. Replay turns each switch on and off in turn.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add toggleUsage
The demo above, as code.
demo.tsx
'use client';
import * as React from 'react';
import { Toggle } from './toggle';
const STEP_MS = 450;
const INITIAL = [true, false, false, true, false, false, false, true];
export default function ToggleDemo({ replay = 0 }: { replay?: number }) {
const [on, setOn] = React.useState(INITIAL);
const set = (i: number) => (value: boolean) => setOn((list) => list.map((v, j) => (j === i ? value : v)));
React.useEffect(() => {
if (replay === 0) return;
setOn(INITIAL);
// Every switch that is not disabled turns over in turn, then back again.
const order = [0, 1, 3, 4, 6, 7];
const timers = [...order, ...order].map((i, step) =>
window.setTimeout(() => setOn((list) => list.map((v, j) => (j === i ? !v : v))), (step + 1) * STEP_MS),
);
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div className="flex flex-col items-center gap-10">
<div className="grid gap-x-12 gap-y-8 sm:grid-cols-2">
<div className="flex flex-col gap-4">
<Toggle label="Email notifications" checked={on[0]} onCheckedChange={set(0)} />
<Toggle
label="Push notifications"
supportingText="Get a message on your phone when someone mentions you."
checked={on[1]}
onCheckedChange={set(1)}
/>
<Toggle label="SMS notifications" checked={on[2]} disabled />
</div>
<div className="flex flex-col gap-4">
<Toggle
type="slim"
size="md"
label="Email notifications"
checked={on[3]}
onCheckedChange={set(3)}
/>
<Toggle
type="slim"
size="md"
label="Push notifications"
supportingText="Get a message on your phone when someone mentions you."
checked={on[4]}
onCheckedChange={set(4)}
/>
<Toggle type="slim" size="md" label="SMS notifications" checked={on[5]} disabled />
</div>
</div>
<div className="flex items-center gap-6">
<Toggle size="md" aria-label="Dark mode" checked={on[6]} onCheckedChange={set(6)} />
<Toggle type="slim" aria-label="Sounds" checked={on[7]} onCheckedChange={set(7)} />
</div>
</div>
);
}
API
Parts: root (label element, the whole row is the tap area), track, thumb, text: label and supporting text.
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | undefined | Controlled on state. |
| defaultChecked | boolean | false | On state when uncontrolled. |
| type | "default" | "slim" | "default" | Default has a thumb inside the track; slim has a thin track and a full-height thumb with a border. |
| size | "sm" | "md" | "sm" | Default: 36 x 20 or 44 x 24px. Slim: 32 x 16 or 40 x 20px. |
| label | string | undefined | Label text. Without it, pass aria-label. |
| supportingText | string | undefined | Second line under the label. |
| disabled | boolean | false | Fades the switch to 50%; the text stays at full opacity. |
| required | boolean | false | Native required for form validation. |
| name | string | undefined | Form field name. |
| value | string | "on" | Value sent with the form when on. |
| Event | Description |
|---|---|
| checkedChange | The switch turned on or off. Payload: the new state. |
| Slot | Description |
|---|---|
| default | Label content, replaces the label prop. |
| supportingText | Supporting content, replaces the supportingText prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TGL1 | checked -> true / false | thumb | translate x | 0 (off side) → track width - thumb width - 2 x inset (on side) | duration-xl (380)ease-throw | Moves at once |
| TGL2 | checked -> true / false | track | background-color | bg-control-track → bg-brand-solid | duration-lg (320)ease-out | Swaps at once |
| TGL3 | checked -> true / false (slim) | thumb border | border-color | toggle-border → toggle-slim-border_pressed | duration-lg (320)ease-out | Swaps at once |
| TGL4 | hover the switch or its text | track (slim: thumb border) | background-color, border-color | bg-control-track or bg-brand-solid → bg-control-track_hover or bg-brand-solid_hover (slim pressed: toggle-slim-border_pressed-hover) | duration-sm (200)ease-out | Swaps at once |
| TGL5 | keyboard focus | focus layer behind the thumb | box-shadow | none → focus ring (2px bg-primary gap, 4px focus-ring) | duration-2xs (120)ease-out | Shows at once |
Accessibility
- Built on a switch primitive: role switch with aria-checked; Space and Enter toggle, and a hidden native input takes part in forms.
- The label and supporting text are linked with aria-labelledby and aria-describedby; clicking the text toggles the switch.
- A switch acts at once: use it for settings that take effect right away, and a checkbox for choices that are sent with a form button.
- Default sm is 20px tall and slim is 16 or 20px: without text, give the switch a 24 x 24px tap area or 4px of space around it (WCAG 2.5.8). With text, the whole row is the tap area.
- The on state is shown by the thumb position and the track color, not by color alone.
- Reduced motion: the thumb jumps to its side and the track color swaps at once.