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

proptypedefault
checked?booleancontrolled, alongside onChange
defaultChecked?booleanuncontrolled, read by the form
disabled?booleanfalsenative, so it also leaves the tab order
…restprops of inputthe 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.

tsx
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.

tsx
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.

tsx
<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.

html
<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.

css
.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.

tsx
<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.