Toggle
A switch, and underneath a native checkbox. The browser keeps the behaviour and the semantics; CSS draws the track and slides the thumb.
API
Props
| prop | type | default | |
|---|---|---|---|
checked? | boolean | — | controlled, alongside onChange |
defaultChecked? | boolean | — | uncontrolled, read by the form |
disabled? | boolean | false | native, so it also leaves the tab order |
…rest | props of input | — | the type and the role are owned by the component |
Examples
Usage
Controlled like any input, with a Label beside it. Clicking the word flips the switch, because the caption points at the control with htmlFor and the browser does the rest.
const [checked, setChecked] = useState(true);
<Toggle id="notifications" checked={checked} onChange={(event) => setChecked(event.target.checked)} />
<Label as="label" htmlFor="notifications">notifications</Label>A list of settings
Where a toggle belongs: a row per setting, the caption on the left and the switch on the right, each one taking effect the moment it moves. The kit sets nothing about that layout — the row is yours.
const [enabled, setEnabled] = useState<readonly string[]>(["notifications"]);
const toggle = (setting: string) =>
setEnabled((current) =>
current.includes(setting) ? current.filter((item) => item !== setting) : [...current, setting],
);
<List
array={settings}
itemExtractor={({ row }) => (
<div key={row} className="flex items-center justify-between gap-4">
<Label as="label" htmlFor={row}>{row}</Label>
<Toggle id={row} checked={enabled.includes(row)} onChange={() => toggle(row)} />
</div>
)}
/>States
Off, on, and the same two disabled. Disabled is the native attribute, so the switch also leaves the tab order; the kit only dims it, and stops the hover from answering a pointer that cannot use it.
<Toggle defaultChecked={false} />
<Toggle defaultChecked />
<Toggle defaultChecked disabled />
<Toggle disabled />Details
A checkbox that says switch
The element is an input[type="checkbox"] carrying role="switch", which is the one role the specification lets a checkbox take. Everything native stays: the space bar, the label, the form serialising it under its name. What changes is the announcement — on and off instead of checked and unchecked, which is what a switch is.
<input type="checkbox" role="switch" class="margo-toggle" />Nothing else is declared. There is no state in React, no key handler, and no second hidden input to make a form work: the control is the input, and the component is a class list on it.
The thumb and its travel
The track is the input and the thumb is its ::before, a square that takes the full inner height and rounds itself into a circle. Checked moves it with translate, the property the compositor can animate without touching layout.
.margo-toggle::before {
block-size: 100%;
aspect-ratio: 1;
background-color: currentColor;
transition: translate 160ms ease-out;
}
.margo-toggle:checked::before {
translate: var(--margo-toggle-travel, 1rem) 0;
}The distance is the one number the component cannot infer: it is the track's width minus its height, because the border and the padding cancel out on both axes. The default matches the default size; resize the switch and you own that subtraction too.
<Toggle className="h-6 w-11 [--margo-toggle-travel:1.25rem]" />What it is for
A switch says the change is already in effect. Flipping it is the action, not the preparation for one, so it belongs where the result is immediate and reversible — a preference, a mode, a feature that turns on while you watch. Where the answer is only collected, to be submitted later, a switch promises something the screen has not done yet.
The thumb keeps one colour whether the switch is on or off, and hover leaves it alone: it dims only when the control is disabled. A greyed thumb on a live switch reads as unavailable, which is the one thing it must never say.