Toggle
One native input and one pseudo-element: the input box is the track, its ::after is the thumb, and the thumb's position is the state. Every other measurement is worked out from the track height it shares with Checkbox.
- Stable
- React 19
- role="switch" pinned
- One DOM node
Code
<Toggle label="Airplane mode" defaultChecked /><div class="wayra-label" data-placement="right" data-size="md"><input type="checkbox" role="switch" class="wayra-toggle" id="usage-toggle" checked /><label class="wayra-label-text" for="usage-toggle">Airplane mode</label></div>Reach for Toggle when flipping it does something immediately. Reach for Checkbox when the value is selected now and submitted later. That is the whole decision, and it is why these are two components rather than one with a prop.
Under the hood it is still a checkbox input. role="switch" changes what it announces, not
what it submits.
airplane=false · wifi=true · bluetooth=true
Code
import { useState } from "react";import { Toggle } from "wayra-ui";
export function SettingsDemo() { const [airplane, setAirplane] = useState(false); const [wifi, setWifi] = useState(true); const [bluetooth, setBluetooth] = useState(true);
// Flipping airplane mode does something immediately — which is the whole // reason this is a Toggle and not a Checkbox. function flipAirplane(on: boolean) { setAirplane(on); if (on) { setWifi(false); setBluetooth(false); } }
return ( <div className="hu-demo-col"> <div className="hu-settings"> <Toggle label="Airplane mode" labelPlacement="left" checked={airplane} onChange={(event) => flipAirplane(event.currentTarget.checked)} /> <Toggle label="Wi-Fi" labelPlacement="left" color="success" checked={wifi} disabled={airplane} onChange={(event) => setWifi(event.currentTarget.checked)} /> <Toggle label="Bluetooth" labelPlacement="left" color="info" checked={bluetooth} disabled={airplane} onChange={(event) => setBluetooth(event.currentTarget.checked)} /> </div> <p className="hu-readout"> airplane={String(airplane)} · wifi={String(wifi)} · bluetooth= {String(bluetooth)} </p> </div> );}Playground
Section titled “Playground”Change the props and copy the code.
Generated code
<Toggle
label="Airplane mode"
/>Colors
Section titled “Colors”Code
<Toggle color="primary" aria-label="primary" defaultChecked /><Toggle color="secondary" aria-label="secondary" defaultChecked /><Toggle color="accent" aria-label="accent" defaultChecked /><Toggle color="neutral" aria-label="neutral" defaultChecked /><Toggle color="info" aria-label="info" defaultChecked /><Toggle color="success" aria-label="success" defaultChecked /><Toggle color="warning" aria-label="warning" defaultChecked /><Toggle color="error" aria-label="error" defaultChecked /><input type="checkbox" role="switch" class="wayra-toggle" data-color="primary" checked aria-label="primary" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="secondary" checked aria-label="secondary" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="accent" checked aria-label="accent" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="neutral" checked aria-label="neutral" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="info" checked aria-label="info" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="success" checked aria-label="success" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="warning" checked aria-label="warning" /><input type="checkbox" role="switch" class="wayra-toggle" data-color="error" checked aria-label="error" />On, the track takes the role and the thumb takes its -content. Those two clear 4.5:1
against each other under the contract every theme is built to, so the 3:1 that WCAG 1.4.11
wants of a control boundary comes free.
Off, the role does not show. A switch has one meaningful color, not two, so the off track
is the same muted mix the hints use — about 5.6:1 light / 6.2:1 dark against base-100 in
the default theme.
That is comfortably past the 3:1 a control outline needs, which is why the track carries no
contrasting border of its own and reads as one solid pill.
Every length comes out of the track height. There is no second free number, and the height
is the same ladder Checkbox and Radio use for their box, so a settings form that mixes
switches and checkboxes lines up at any one size.
Code
<Toggle size="xs" aria-label="xs" defaultChecked /><Toggle size="sm" aria-label="sm" defaultChecked /><Toggle size="md" aria-label="md" defaultChecked /><Toggle size="lg" aria-label="lg" defaultChecked /><Toggle size="xl" aria-label="xl" defaultChecked /><input type="checkbox" role="switch" class="wayra-toggle" data-size="xs" checked aria-label="xs" /><input type="checkbox" role="switch" class="wayra-toggle" data-size="sm" checked aria-label="sm" /><input type="checkbox" role="switch" class="wayra-toggle" data-size="md" checked aria-label="md" /><input type="checkbox" role="switch" class="wayra-toggle" data-size="lg" checked aria-label="lg" /><input type="checkbox" role="switch" class="wayra-toggle" data-size="xl" checked aria-label="xl" />| Size | Track height | Track width | Thumb travel |
|---|---|---|---|
xs |
16px | 32px | 16px |
sm |
18px | 36px | 18px |
md |
20px | 40px | 20px |
lg |
24px | 48px | 24px |
xl |
28px | 56px | 28px |
The track is a flat 2:1. That sits in the usual switch range, and it is the only ratio in that range where every step lands on a whole pixel. A thumb that travels a fractional pixel renders blurry.
The thumb itself is 0.82 of the track’s content box, and the travel simplifies to exactly
width − height, so borders and padding cancel out whatever a theme does.
Validation
Section titled “Validation”Code
<Toggle aria-invalid aria-label="Invalid on" defaultChecked /><Toggle aria-invalid aria-label="Invalid off" /><input type="checkbox" role="switch" class="wayra-toggle" aria-invalid="true" checked aria-label="Invalid on" /><input type="checkbox" role="switch" class="wayra-toggle" aria-invalid="true" aria-label="Invalid off" />:user-invalid and [aria-invalid="true"] both re-point the accent at the error role, so
the hover mixes and the focus ring follow with no extra rules.
On an off switch the border is the only thing that can carry it, because the track has to
stay the gray that means “off”. That leaves the red sitting on a track close to it in
lightness, so the border is never the only signal: the hint turns red too, role="alert"
announces the message, and aria-invalid sits on the control itself.
Nothing to configure. name, ref, checked, defaultChecked and onChange all come
from ComponentProps<"input">, so a native form, TanStack Form or Formik work with no
adapter. The integration point is error plus aria-invalid.
react-hook-form is built in, exactly as it is on
Input. Inside Form — which provides the useForm instance
you own — a Toggle registers itself on name alone: no register spread, the validation
message arrives in the hint slot, and the switch disables itself while the form submits.
An explicit error or disabled still wins, and noRhf turns the whole thing off for one
instance. Outside a Form, spread register("terms") onto the switch and it binds the
same way — the Toggle composes the handlers and merges the ref for you.
The submitted value is a boolean. The pinned role="switch" plays no part in that:
react-hook-form reads the pinned type="checkbox". One exception, and it is
react-hook-form’s rule for every checkbox rather than this component’s: a switch carrying a
non-empty value posts that string instead of true.
const schema = z.object({ terms: z.boolean().refine((accepted) => accepted, "Accept the terms first."),});
const form = useForm({ resolver: zodResolver(schema) });
<Form form={form} onSubmit={save}> <Toggle name="terms" label="Accept the terms" /> <Button type="submit">Save</Button></Form>A native form is the one place the boolean does not survive: FormData posts the string
"on" for a checked box and omits the key entirely for an unchecked one. That is the
platform, not this component.
What’s not included
Section titled “What’s not included”No mixed state
Section titled “No mixed state”There is no indeterminate, not even in CSS. ARIA forbids a mixed state on a switch, and
there is nowhere to draw one — the thumb is at one end or the other. Where checkbox.css
draws a dash, toggle.css ships nothing.
No shape
Section titled “No shape”The pill track with a round thumb is how a switch is recognized, the same way a circle is
how a radio is. The radius is a literal pill and never --wayra-radius-selector, so
brutal squares its checkboxes and keeps its switches round. A square track with a
round thumb looks like a broken slider.
No role prop
Section titled “No role prop”role="switch" is pinned after the props spread, the way Button pins type="button". A
control that announces “on/off” and one that announces “checked/unchecked” are different
components here, and Checkbox is the other one. The native checked state maps to
aria-checked on its own, so there is no ARIA bookkeeping to do.
Label, helper, and error
Section titled “Label, helper, and error”Uses your data plan.
Code
<Togglelabel="Sync over cellular"labelPlacement="left"helper="Uses your data plan."defaultChecked/><div class="wayra-label" data-placement="left" data-size="md" style="width:18rem; grid-template-columns: 1fr auto"><input type="checkbox" role="switch" class="wayra-toggle" id="tg-helper" checked aria-describedby="tg-helper-hint" /><label class="wayra-label-text" for="tg-helper">Sync over cellular</label><p class="wayra-label-hint" id="tg-helper-hint">Uses your data plan.</p></div>Same contract as Checkbox: labelPlacement is right (default)
or left, there is one hint slot, and error takes that slot with role="alert".
Input is the canonical page for all of it.
Settings lists usually want left. The name reads as the row heading, and the switches
line up down the right edge.
API reference
Section titled “API reference”ToggleProps extends ComponentProps<"input"> minus type, size, color, and role.
| Prop | Type | Default | Description |
|---|---|---|---|
color |
WayraColor |
"primary" |
The track when on. All eight roles, neutral included. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Track height, off --wayra-size-selector. |
label |
ReactNode |
— | Wraps the switch in the label contract. |
labelPlacement |
"right" | "left" |
"right" |
left is the settings-row layout. |
helper |
ReactNode |
— | Persistent hint, wired into aria-describedby. |
error |
ReactNode |
— | Takes the hint slot, adds role="alert", sets aria-invalid. |
unstyled |
boolean |
false |
Drops the classes, keeps the data attributes. |
checked, defaultChecked, name, value, onChange, and ref come free from the
native input, which is all a form library needs. There is no width and no shape.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Tab |
Moves focus. Each switch is its own tab stop. |
Space |
Toggles. Native — there is no key handler in the component. |
- Focus uses
:focus-visible, drawn once instates.css, so a mouse click leaves no ring. - Naming comes from
label, or fromaria-labelwhen the switch renders bare. Name the setting, never the state. - It announces on/off, not checked/unchecked, because
role="switch"is pinned. - Windows high contrast fills the track with
Highlightwhen on and keeps the thumb’sCanvasTextoutline in both states, so it stays visible on either fill. - A press stretches the thumb a quarter longer, from the edge it rests on, in RTL too, and the track takes its hover step while the pointer is down.
- Reduced motion removes the slide, never the information. The thumb jumps to the other end and its position still says how the switch is set. A press no longer stretches it; the track still darkens.
- RTL flips the travel.
translateis physical while a switch moves along the text direction, so adir="rtl"on the switch or on any ancestor negates the distance, down to the browser floor.:dir()then puts an LTR island inside RTL back where the engine has it (Chrome 120 / Safari 16.4). - Disabled fades the switch, its label, and its hint by the same amount.
- Contrast: on, the thumb’s
-contentclears 4.5:1 against the role track. Off, the muted track is about 5.6:1 light / 6.2:1 dark againstbase-100in the default theme.
Do and don’t
Section titled “Do and don’t”Do
Use a switch for a setting that takes effect the instant it is flipped. No Save button in sight.
Do
Label the switch with the setting it controls. The thumb position already says on or off.
Don't
A label that names the state instead of the setting. The switch already says on; the words have to say on what.
Do
labelPlacement set to left for a settings list. The names read as row headings and the switches line up down one edge.
Don't
The default right placement in the same list. The switches sit at a different offset on every row.