Switch
When to use
Section titled “When to use”- Settings that apply immediately: dark mode, notifications
- Turning a feature on or off in a settings list
Not in a form that is submitted — use a Checkbox, since a switch suggests the change already happened.
Choosing between Checkbox, Switch or Toggle:
| Component | Use it for | For example |
|---|---|---|
| Checkbox | A yes/no choice, or one of several independent options, that applies when the form is submitted | “I agree to the terms”, “Email me updates” |
| Switch | A setting that takes effect the moment it is flipped, with no Save button | “Dark mode”, “Wi-Fi”, “Notifications on” |
| Toggle | A button that stays pressed, usually in a toolbar, for a mode or formatting | “Bold”, “Mute”, “Pin” |
import { Switch } from '@arun-dev/ui';
export default function SwitchBasics() { return ( <> {/* A switch has no accessible name of its own — the label supplies it, associated by id so jsx-a11y accepts the <button> the switch renders. */} <label htmlFor="notifications" style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' }} > <Switch.Root id="notifications"> <Switch.Thumb /> </Switch.Root> Notifications </label>
<label htmlFor="analytics" style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' }} > <Switch.Root id="analytics" defaultChecked> <Switch.Thumb /> </Switch.Root> Analytics </label>
<label htmlFor="disabled-example" style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' }} > <Switch.Root id="disabled-example" disabled> <Switch.Thumb /> </Switch.Root> Disabled </label> </> );}It needs a name
Switch.Root has no accessible name of its own — a headless component should not guess at your
copy. Associate a <label> with it by id:
<label htmlFor="notifications"> <Switch.Root id="notifications"> <Switch.Thumb /> </Switch.Root> Notifications</label>Switch.Root renders a <button>, so wrapping it in a <label> would name it implicitly and
the htmlFor looks redundant. Keep it. jsx-a11y/label-has-associated-control only accepts
input, meter, output, progress, select and textarea as a nested control, so it
rejects a <button> that HTML labels perfectly well. The explicit association satisfies the
rule, and you avoid disabling it at every call site.
Where there is no visible copy, pass aria-label or aria-labelledby instead.
A thumb with an icon
Section titled “A thumb with an icon”Switch.Thumb is a real element, not a pseudo-element, so it can hold content — an icon, a
loading spinner. That is why the switch is two parts.
import { useState } from 'react';import { Switch } from '@arun-dev/ui';
/** * A thumb with an icon in it — the reason `Switch.Thumb` is a real element rather * than a pseudo-element. The icon swaps with the state the Root already tracks. */export default function SwitchThemeToggle() { const [dark, setDark] = useState(false);
return ( <label className="ds-theme-toggle" htmlFor="theme"> <Switch.Root id="theme" checked={dark} onCheckedChange={setDark}> <Switch.Thumb className="ds-theme-thumb">{dark ? <Moon /> : <Sun />}</Switch.Thumb> </Switch.Root> {dark ? 'Dark' : 'Light'} </label> );}
/* Presentational only. The Root announces the state and the thumb is aria-hidden, so the icons never reach assistive technology — they are not a second source of truth. */
function Sun() { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> <circle cx="12" cy="12" r="4.5" /> <path d="M12 1v3M12 20v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M1 12h3M20 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" /> </svg> );}
function Moon() { return ( <svg viewBox="0 0 24 24" fill="currentColor"> <path d="M21 13.2A9 9 0 1 1 10.8 3a7 7 0 0 0 10.2 10.2Z" /> </svg> );}The icon is decorative: the Root already carries role="switch" and aria-checked, and the
thumb is aria-hidden, so a screen reader hears the state once rather than twice. Size the icon
relative to --switch-thumb-size and it survives a re-scale of the switch tokens.
When the icon should be the thumb
Section titled “When the icon should be the thumb”children puts content inside the thumb. render replaces the thumb’s element, which is what
you want when the icon component renders its own root — the wrapper span disappears, and the
icon receives className, aria-hidden and the data-* state directly, so it can react to
data-checked itself.
import { useState } from 'react';import type { ComponentPropsWithoutRef } from 'react';import { Switch } from '@arun-dev/ui';
/** * `render` on the thumb, where `children` cannot help: the icon *is* the thumb rather * than sitting inside one. * * The icon component owns its root element, so it receives `className`, `aria-hidden` * and the `data-*` state directly — which is what lets it react to `data-checked` * itself, with no wrapper span in between. */export default function SwitchThumbRender() { const [dark, setDark] = useState(false);
return ( <label className="ds-theme-toggle" htmlFor="theme-render"> <Switch.Root id="theme-render" checked={dark} onCheckedChange={setDark}> <Switch.Thumb className="ds-icon-thumb" render={<ThemeIcon dark={dark} />} /> </Switch.Root> {dark ? 'Dark' : 'Light'} </label> );}
/** * Renders its own `<svg>` root and spreads everything it is given onto it — the same * shape as any icon component, or as `motion.span` from an animation library. * * The viewBox is larger than the artwork so the glyph keeps a margin inside the circle * the thumb draws; an `<svg>` has no padding of its own. */function ThemeIcon({ dark, ...rest }: { dark: boolean } & ComponentPropsWithoutRef<'svg'>) { return ( <svg viewBox="0 0 32 32" {...rest}> {dark ? ( <path fill="currentColor" d="M25 20.2A9 9 0 1 1 15.8 11a7 7 0 0 0 9.2 9.2Z" /> ) : ( <g fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"> <circle cx="16" cy="16" r="4.5" /> <path d="M16 6v2M16 24v2M9 9l1.5 1.5M21.5 21.5 23 23M6 16h2M24 16h2M9 23l1.5-1.5M21.5 10.5 23 9" /> </g> )} </svg> );}The same shape covers the other reasons to reach for it: handing the element to an animation
library that must own it (render={<motion.span />}), or making the thumb a void element such as
an <img>, which children cannot do. For an icon that simply sits inside the thumb, prefer
children — it is one less moving part.
Playground
Section titled “Playground”<Switch.Root><Switch.Thumb /></Switch.Root>Controlled
Section titled “Controlled”Pass checked with onCheckedChange and the parent owns the value — the switch will not move
on its own. onCheckedChange reports the value being moved to in both modes, so the same
handler works either way.
import { useState } from 'react';import { Switch } from '@arun-dev/ui';
export default function SwitchControlled() { const [enabled, setEnabled] = useState(false);
return ( <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}> <label htmlFor="dark-mode" style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' }} > <Switch.Root id="dark-mode" checked={enabled} onCheckedChange={setEnabled}> <Switch.Thumb /> </Switch.Root> Dark mode </label>
{/* The parent owns the value, so the switch only moves when state changes. */} <span className="text-size-sm text-color-secondary">{enabled ? 'on' : 'off'}</span> </div> );}Never pass an undefined checked
The mode is decided once, at mount, and never re-evaluated. A checked of undefined on the
first render makes the switch uncontrolled for the rest of its life — it starts tracking its
own state and ignores every value you pass afterwards. This bites when the value loads
asynchronously. Coalesce at the call site:
<Switch.Root checked={x ?? false} onCheckedChange={setX} />defaultChecked does not guard against this. It is ignored in controlled mode, and only makes
the first render look correct while the switch has already taken ownership.
With react-hook-form
Section titled “With react-hook-form”Bind it with Controller, not register() — register fails quietly here, because it returns
props for a native input and the state lives in React. See the
react-hook-form guide.
<Controller control={control} name="notifications" render={({ field }) => ( <Switch.Root checked={field.value} onCheckedChange={field.onChange} onBlur={field.onBlur} aria-label="Notifications" > <Switch.Thumb /> </Switch.Root> )}/>Anatomy
Section titled “Anatomy”import { Switch } from '@arun-dev/ui';
<Switch.Root> <Switch.Thumb /></Switch.Root>;Switch.Thumb reads its state from Switch.Root, so the two cannot get out of step.
Switch.Root
Section titled “Switch.Root”| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | Controlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount, so write checked={x ?? false}. | |
defaultChecked | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onCheckedChange | (checked: boolean) => void | Called with the value being moved to, in both controlled and uncontrolled modes. | |
disabled | boolean | false | Prevents activation. |
name | string | Submits with the enclosing form when checked. An unchecked switch contributes nothing, mirroring a native checkbox. | |
value | string | 'on' | Value submitted when checked. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Typing a wrapper
Section titled “Typing a wrapper”@arun-dev/ui does not export a props type for the switch. The type is defined by
@arun-dev/headless, and a re-export would only record where it lives today: the moment
@arun-dev/ui adds a prop of its own, the alias is wrong, and wrong silently, because it still
compiles and merely under-reports. Derive it from the component instead — that is a reference to
the component rather than a snapshot of its type’s origin, so it survives the change:
import type { ComponentProps } from 'react';import { Switch } from '@arun-dev/ui';
function NotificationSwitch(props: ComponentProps<typeof Switch.Root>) { return ( <Switch.Root {...props}> <Switch.Thumb /> </Switch.Root> );}Using the headless parts directly, the types are exported by the package that owns them:
import type { SwitchRootProps, SwitchThumbProps } from '@arun-dev/headless/switch'.
Behaviour
Section titled “Behaviour”-
Always a native
<button>, so focus,SpaceandEntercome from the platform rather than from JavaScript. Carriesrole="switch"andaria-checkedper the WAI-ARIA switch pattern. Those come from the button element, so arenderthat produces something else is the consumer’s choice — nothing checks or compensates for it. -
disabledis the button’s own attribute, so the platform removes it from the tab order and suppresses activation.data-disabledis emitted alongside it for styling. -
type="button", so it never submits an enclosing form by accident. -
With a
name, it renders a hidden<input type="checkbox">carrying the value, soform.reset()andform.elementstreat it as the checkbox it behaves like. -
Both parts emit
data-checked/data-unchecked/data-disabled. Styling reads those rather than knowing how the component decides its state:.switch[data-checked] {background: var(--switch-track-bg-checked);}.switch-thumb[data-checked] {transform: translateX(100%);} -
Switch.Thumbisaria-hidden— the Root already announces the state. -
The thumb transition is disabled under
prefers-reduced-motion.
Styling it yourself
Section titled “Styling it yourself”The behaviour, keyboard handling and accessibility all come from
@arun-dev/headless, which ships no CSS and no class names. @arun-dev/ui adds a
stylesheet on top. To bring your own instead, take the same API from the package underneath:
import { Switch } from '@arun-dev/headless/switch';Identical props and parts, styled entirely by you.