Skip to content

Switch

  • 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:

ComponentUse it forFor example
CheckboxA yes/no choice, or one of several independent options, that applies when the form is submitted“I agree to the terms”, “Email me updates”
SwitchA setting that takes effect the moment it is flipped, with no Save button“Dark mode”, “Wi-Fi”, “Notifications on”
ToggleA 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.

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.

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.

<Switch.Root><Switch.Thumb /></Switch.Root>

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.

off
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.

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>
)}
/>
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 props
PropTypeDefaultDescription
checkedbooleanControlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount, so write checked={x ?? false}.
defaultCheckedbooleanfalseInitial state when uncontrolled. Read once, at mount.
onCheckedChange(checked: boolean) => voidCalled with the value being moved to, in both controlled and uncontrolled modes.
disabledbooleanfalsePrevents activation.
namestringSubmits with the enclosing form when checked. An unchecked switch contributes nothing, mirroring a native checkbox.
valuestring'on'Value submitted when checked.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

@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'.

  • Always a native <button>, so focus, Space and Enter come from the platform rather than from JavaScript. Carries role="switch" and aria-checked per the WAI-ARIA switch pattern. Those come from the button element, so a render that produces something else is the consumer’s choice — nothing checks or compensates for it.

  • disabled is the button’s own attribute, so the platform removes it from the tab order and suppresses activation. data-disabled is 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, so form.reset() and form.elements treat 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.Thumb is aria-hidden — the Root already announces the state.

  • The thumb transition is disabled under prefers-reduced-motion.

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.