Components/Form
Toggle
Toggles are switches for a setting that takes effect the moment it is flipped. Nothing is staged and there is nothing to submit afterwards, so a switch suits a change that is instant and easy to reverse. The words beside it name the thing being switched, and the switch itself says which way it is set.
import { Toggle } from '@growth-protocol-ai/alpha';When to use
Reach for a toggle when flipping it is the action — a setting that applies on the spot and needs no Save under it, like a notification a reader turns off or a preview they turn on. Name it for the thing being switched rather than for the act: a name written as an instruction has to be read twice, and it disagrees with the control the moment the reader flips it.
When not to use
Do not use a switch for a value that travels with a form and lands on submit — that is a Checkbox, whatever the two look like side by side. The test is what happens if the reader closes the page a second after clicking: if the change is already made it is a switch, and if it is waiting for a Save it is a checkbox. Answers that rule each other out are a Radio group.
| State | When to use |
|---|---|
Off | The resting rendering: a recessed --surface-depth-1 track with the thumb at the near end. Nothing about it is dimmed — off is one of the two values rather than the absence of one. There is deliberately no hover, because the switch has two things to say and a third signal under the cursor competes with them rather than helping. |
On | The track fills with --button-primary and the thumb slides the distance the track has left over, and the two together are what carry the change. Reduced motion drops the slide and keeps the colour change, which carries the value on its own; focus rings the track rather than the label, at either value. |
Disabled, off | Repainted rather than faded: the track takes one flat --surface-disabled fill, the label greys and the cursor turns away. The keyboard skips it and a submitted form leaves it out, so nothing downstream sees the value at all. |
Disabled, on | The same flat grey as a disabled switch that is off, because the colour has stopped claiming this one can be flipped — which leaves the thumb’s position as the only thing saying which of the two values is locked in. |
role="switch" is fixed and cannot be passed. It is the only thing separating this from a checkbox in the accessibility tree, and it is what makes a screen reader say on and off rather than checked and unchecked. Everything else — the keyboard, the space bar, the form value, the label association — is the native checkbox underneath, which is why there is one.
A switch with no text beside it announces itself as a switch and nothing more. Where the name belongs elsewhere in the layout — the first column of a settings table, say — leave the text out and point your own <label htmlFor> or an aria-label at the input, so the pairing survives for a screen reader as well as for the eye.
A column of switches is a list of independent settings rather than a group. There is nothing for two of them to share — no common name, no mutual exclusion, no group component — and each applies its own change on its own, so what holds a settings panel together is that its rows are named in parallel.
There is no pending state, on purpose: a switch that waits for a server is a design this iteration does not have. Apply the change optimistically and revert it if the write fails, or set disabled for as long as the write is in flight. Do not paint a spinner in through className.
Disabled must never be the only thing saying a setting is unavailable. One grey track says the switch cannot be flipped and nothing about why, so give the reason in text beside it — and prefer leaving the row out altogether over showing a switch nobody can use.
The label follows the control. A row that puts the text first and the switch hard right is a layout rather than a prop — build it in your own markup and reach the control through [data-slot="toggle"], the same selector a consuming app gets, rather than past it for the track.
One geometry, and no size prop. The thumb and the distance it travels are written in the stylesheet rather than derived from the track, so a track resized through className stops lining up with both.
Checkbox, Radio and Toggle are one family and share their root, their label prop, their focus ring and their disabled treatment. Change one and change the others — drift between them reads as a bug to everyone but the person who wrote it.
Controlled value
checked with onChange, where the state lives in yours. A switch applies its change as it is flipped rather than staging it for a submit, so the handler is where the change is actually made — and a handler that does not write the new value back leaves the switch stuck, which is the one way a controlled switch looks broken.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | — | Text beside the track, in a span of the component’s own. Omit it and the switch is unnamed until your own <label htmlFor> names it. |
checked | boolean | — | Controlled value. Pair it with onChange. |
defaultChecked | boolean | false | Initial value when uncontrolled, after which the switch holds its own. |
onChange | (event: ChangeEvent<HTMLInputElement>) => void | — | Every flip, from a click on the track, on the label, or the space bar. Read event.target.checked. |
disabled | boolean | false | Non-interactive, and repainted rather than faded: one grey track at either value, with the thumb still where the value puts it. |
children | never | — | None, so that the text beside the track is the component’s own span rather than a bare string it cannot size or colour. |