Skip to content

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>
);
}

Change the props and copy the code.

color
size
label
state
Generated code
TSX
<Toggle
  label="Airplane mode"
/>
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.

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.

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.

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.

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.

Uses your data plan.

Code
<Toggle
label="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.

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.

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 in states.css, so a mouse click leaves no ring.
  • Naming comes from label, or from aria-label when 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 Highlight when on and keeps the thumb’s CanvasText outline 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. translate is physical while a switch moves along the text direction, so a dir="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 -content clears 4.5:1 against the role track. Off, the muted track is about 5.6:1 light / 6.2:1 dark against base-100 in the default theme.

Do

Use a switch for a setting that takes effect the instant it is flipped. No Save button in sight.

Don't

A switch for a value submitted later. Consent is a form field, so it belongs to Checkbox.

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.