Skip to content

Switch

ui

Preview

<label class="flex items-center gap-2">
  <input type="checkbox" role="switch" class=${switchInputClass()} name="notify" checked>
  <span class=${switchTrackClass()}></span>
  <span class=${labelClass()}>Notifications</span>
</label>

Installation

webjs ui add switch

Sizes

<label class="inline-flex items-center gap-2">
  <input type="checkbox" data-slot="switch" class=${switchInputClass()} checked>
  <span class=${switchTrackClass({ size: 'default' })}></span>
  <span class=${labelClass()}>Default</span>
</label>


<label class="inline-flex items-center gap-2">
  <input type="checkbox" data-slot="switch" class=${switchInputClass()} checked>
  <span class=${switchTrackClass({ size: 'sm' })}></span>
  <span class=${labelClass()}>Small</span>
</label>

API Reference

Parts

Name Description
switchInputClass() Hidden native checkbox that holds the state.
switchTrackClass({ size }) Visible track with the sliding thumb pseudo-element.

Props

Prop Type Default Description
size "default" | "sm" "default" webjs extension, shadcn has no size prop.
checked boolean (on the <input>) false
disabled boolean false

Source: components/ui/switch.ts

/**
 * Switch: toggle styled as a sliding pill. Tier-1 class helpers. A
 * hidden native `<input type="checkbox" role="switch">` handles form
 * submission + keyboard; a sibling `<span>` provides the visual track +
 * thumb (positioned via the `peer-checked:` variant).
 *
 * shadcn parity:
 *   Switch (size: default | sm)  → switchInputClass() on a hidden <input> +
 *                                   switchTrackClass({ size }) on a sibling <span>
 *
 * Design tokens used: --primary, --input, --background, --foreground, --ring,
 * --primary-foreground.
 *
 * A11y (required for accessible output):
 *   Keep `role="switch"` on the native checkbox. That is what makes a screen
 *   reader announce "on / off" rather than "checked / unchecked", and the
 *   browser maps the input's checked state to it for free.
 *   NAME IT. The visible track is a `<span>` and the real control is `sr-only`,
 *   so there is no text on the input at all. Wrapping the whole thing in a
 *   `<label>` (the first example) is the simplest way, since the label's text
 *   then names the input. A STANDALONE switch, one with no wrapping `<label>`
 *   and no `<label for>` pointing at it, has NO accessible name and needs an
 *   explicit `aria-label`, as the second example shows.
 *   The visual track and thumb are presentation only. They carry no text and no
 *   role, so nothing about the state reaches assistive tech through them: it all
 *   comes from the input. Do not move the name onto the track.
 *   Use `disabled` on the input, not on the track: the track styles itself from
 *   the `peer-disabled:` variant, and only the real control can refuse input.
 *
 * @example
 * ```html
 * <label class="inline-flex items-center gap-2">
 *   <input type="checkbox" role="switch" name="notify" class=${switchInputClass()}>
 *   <span class=${switchTrackClass()}></span>
 *   <span class=${labelClass()}>Notifications</span>
 * </label>
 *
 * <!-- Standalone (no wrapping label), so the name has to be explicit. -->
 * <input type="checkbox" role="switch" name="wifi" aria-label="Wi-Fi"
 *        class=${switchInputClass()}>
 * <span class=${switchTrackClass()}></span>
 *
 * <!-- Small size. -->
 * <input type="checkbox" role="switch" name="x" aria-label="Compact mode"
 *        class=${cn(switchInputClass(), 'peer/sm')}>
 * <span class=${switchTrackClass({ size: 'sm' })}></span>
 * ```
 */
import { cn } from '../lib/utils.ts';

/** Hidden native checkbox: handles form value + keyboard activation. */
export const switchInputClass = (): string => 'peer sr-only';

const TRACK_BASE =
  'inline-flex shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none peer-focus-visible:border-ring peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 bg-input dark:bg-input/80 peer-checked:bg-primary relative';

const TRACK_SIZES = {
  default: 'h-[1.15rem] w-8',
  sm: 'h-3.5 w-6',
} as const;

const THUMB_SIZES = {
  default:
    "after:size-4 after:translate-x-px peer-checked:after:translate-x-[calc(100%-2px)]",
  sm: "after:size-3 after:translate-x-px peer-checked:after:translate-x-[calc(100%-1px)]",
} as const;

const THUMB_BASE =
  'after:pointer-events-none after:absolute after:left-0 after:rounded-full after:bg-background after:transition-transform peer-checked:after:bg-primary-foreground dark:after:bg-foreground';

export type SwitchSize = keyof typeof TRACK_SIZES;

export function switchTrackClass(opts: { size?: SwitchSize } = {}): string {
  const size = opts.size ?? 'default';
  return cn(TRACK_BASE, TRACK_SIZES[size], THUMB_BASE, THUMB_SIZES[size]);
}