Building a Toggle / Switch Component from Scratch

Toggle and Checkbox look similar but mean different things. A Checkbox records a choice in a form — you submit it later. A Toggle applies an effect immediately — dark mode on, notifications off, feature enabled. That semantic difference changes the HTML element, the ARIA role, and the user's expectation of what happens when they interact with it.
Building a Toggle / Switch Component from Scratch
The Toggle is visually distinctive — a pill-shaped track with a sliding thumb — but its real difference from Checkbox is semantic. When a user flips a Toggle, something happens right now. When a user ticks a Checkbox, they're recording a preference that gets submitted with a form.
This distinction drives two decisions: we use role="switch" instead of role="checkbox", and we don't wrap it in a form field pattern with an error state. A Toggle is an action control, not a form input.
The four files
Toggle.variants.ts
The thumb translation distances are calculated to move exactly from one end of the track to the other based on the size variant. For md: track width is 44px (w-11), thumb width is 20px (w-5), track border is 2px on each side — so the thumb travels 44 - 20 - 4 = 20px, which is translate-x-5.
We use data-[state=checked] attributes for the checked styling rather than CSS :checked pseudo-class — because we're not using a native checkbox element here. The Toggle is built as a <button> with role="switch", which gives us full control over the visual without the styling constraints of a native input.
Toggle.types.ts
ToggleProps extends HTMLButtonElement — not HTMLInputElement. The Toggle renders as a <button>, not an <input>. This is intentional: role="switch" on a button is a valid and well-supported ARIA pattern, and buttons are focusable and activatable by keyboard without any extra work.
onCheckedChange receives a boolean directly — cleaner than parsing e.target.checked from a synthetic event.
labelPosition controls whether the label appears to the left or right of the toggle — a common layout requirement in settings panels.
Toggle.tsx
What each decision is doing
<button role="switch"> instead of <input type="checkbox"> — a native checkbox with role="switch" is technically valid but inconsistent across screen readers. A <button role="switch"> is the more reliable pattern — it's natively focusable, activatable with Space and Enter, and aria-checked on a button with role="switch" is well-supported across all major screen readers.
data-state attribute for styling — because we're using a button instead of an input, we can't rely on CSS :checked. Instead we set data-state="checked" or data-state="unchecked" on both the track and the thumb, and use Tailwind's data-[state=checked]: variant to apply styles. This pattern — used by Radix UI — is clean, debuggable in DevTools, and works reliably across all browsers.
aria-checked — this is what screen readers read aloud. When the toggle is checked, a screen reader announces "on" or "checked." When unchecked, it announces "off" or "unchecked." Without aria-checked, the user has no way to know the current state.
aria-labelledby pointing to the label — we use aria-labelledby rather than aria-label so the visible label text is used as the accessible name. If the label changes, the accessible name updates automatically. No hardcoded strings in ARIA attributes.
labelPosition — in settings UIs, toggles commonly appear with the label on the left and the control on the right. The labelPosition prop handles this without requiring consumers to manually build the layout.
Controlled and uncontrolled — same isControlled pattern from RadioGroup. If checked is passed, the component is controlled. If only defaultChecked is passed, the component manages its own state.
The component index
src/components/Toggle/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use Toggle for settings that take effect immediately — dark mode, notifications, feature flags, auto-save. Use Checkbox for form inputs where the value is submitted as part of a larger action — accepting terms, selecting items in a list, configuring options before hitting a Save button. If the user needs to click Save after interacting with it, it's a Checkbox. If something changes the moment they flip it, it's a Toggle.
Up next: Building a Label component that actually connects to its field — the Label we've been referencing across Input, Textarea, Checkbox, and Radio finally gets its own proper implementation with required indicators, optional markers, and disabled state handling.
Related: Building a Checkbox component — custom styles without losing native behaviour — Toggle and Checkbox are visually and semantically related. Reading them together clarifies when each is the right choice for a given interaction.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Joshua Reddekopp on Unsplash

