Skip to content
ssntl/ui

Switch

An on/off setting with a quick press and a thumb that gives a little while held.

Installation

$ pnpm dlx shadcn@latest add @ssntl/switch

Usage

tsx
import { Switch, SwitchField } from "@/components/ui/switch"

<SwitchField label="Accept orders" hint="Customers can buy right now" defaultChecked />
<Switch aria-label="Vacation mode" />

Rules

What this component expects from you, as it ships in components/ui/switch.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/switch.md
## Rules for Switch

- **Toggle feedback.** The track presses to 0.96 in 150ms, small and immediate.
- **Squash & stretch.** While held the thumb widens 6% and flattens 6%, then springs back.
- On fills the track with primary, so the state reads without the motion.
- Use a switch for a setting that applies at once; use a Checkbox inside a form you submit.
Open the full rules file (opens in a new tab)

Code

components/ui/switch.tsx
"use client"

import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

// An on/off setting that applies at once (Radix Switch, role="switch").
// - Toggle feedback: the track presses to 0.96 in 150ms, small and quick.
// - Squash & stretch: while held, the thumb widens 6% and flattens 6%, a
//   hint of physical give; it springs back on release.
// - State never rides on motion alone: on fills the track with primary.
function Switch({
  className,
  ...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      className={cn(
        "group inline-flex h-8 w-13 shrink-0 cursor-pointer items-center rounded-full bg-input p-1",
        "transition-[background-color,scale] duration-(--duration-fast) ease-out active:scale-(--es-press) motion-reduce:active:scale-100",
        "data-[state=checked]:bg-primary",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-40",
        className
      )}
      {...props}
    >
      <SwitchPrimitive.Thumb
        data-slot="switch-thumb"
        className={cn(
          "pointer-events-none block size-6 rounded-full bg-card elevation-1",
          "transition-[translate,scale] duration-(--duration-fast) ease-out data-[state=checked]:translate-x-5 rtl:data-[state=checked]:-translate-x-5",
          "group-active:scale-x-106 group-active:scale-y-94 motion-reduce:transition-none motion-reduce:group-active:scale-100"
        )}
      />
    </SwitchPrimitive.Root>
  )
}

/** A full-width row: the label, an optional hint, and the switch at the end. */
function SwitchField({
  label,
  hint,
  id,
  ...props
}: React.ComponentProps<typeof Switch> & { label: string; hint?: string }) {
  const generated = React.useId()
  const controlId = id ?? generated
  return (
    <div
      data-slot="switch-field"
      className="flex min-h-12 items-center justify-between gap-4 py-2"
    >
      <label
        htmlFor={controlId}
        className="grid cursor-pointer gap-0.5 select-none"
      >
        <span className="text-body">{label}</span>
        {hint && (
          <span className="text-caption text-muted-foreground">{hint}</span>
        )}
      </label>
      <Switch id={controlId} {...props} />
    </div>
  )
}

export { Switch, SwitchField }