SwitchNew
pnpm dlx shadcn@latest add …/r/switch.jsonAn accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.
import { Switch } from "@/components/ui/switch"
export function AirplaneMode() {
return (
<>
<Switch id="airplane-mode" />
<label htmlFor="airplane-mode">Airplane mode</label>
</>
)
}import { Switch } from "@/components/ui/switch";
import { useId } from "react";
export default function SwitchDefault() {
const id = useId();
return (
<div className="flex items-center gap-3">
<Switch id={id} />
<label htmlFor={id} className="text-sm font-medium">
Airplane mode
</label>
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-popover: 1400; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --scale-press: 0.97; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); } .dark { --destructive-foreground: oklch(1 0 0); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); @keyframes popover-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } } @keyframes popover-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.96); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring;Copy the source into your project.
"use client"; import { spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { motion, useReducedMotion } from "motion/react"; import { Switch as SwitchPrimitive } from "radix-ui"; import { type ComponentProps, useState } from "react"; const switchVariants = cva( [ "inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 shadow-xs", // The thumb sits at the start or the end; motion's layout animation glides it between the two "justify-start bg-input data-[state=checked]:justify-end data-[state=checked]:bg-primary", "transition-colors duration-(--duration-fast) ease-out", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)", ], { variants: { size: { sm: "h-4 w-7", md: "h-5 w-9", }, }, defaultVariants: { size: "md" }, }, ); const thumbSizes = { sm: { rest: "size-3", pressed: "h-3 w-4" }, md: { rest: "size-4", pressed: "h-4 w-5" }, } as const; export type SwitchProps = ComponentProps<typeof SwitchPrimitive.Root> & VariantProps<typeof switchVariants>; /** * An on/off switch for settings that apply right away, built on Radix Switch (role="switch"). * Give it a visible label (`<label htmlFor>`). */ function Switch({ className, size, checked, defaultChecked, onCheckedChange, onPointerDown, onPointerUp, onPointerLeave, ...props }: SwitchProps) { // Mirror the state so the thumb animation knows when it changed, controlled or not const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false); const on = checked ?? uncontrolled; const [pressed, setPressed] = useState(false); const reduceMotion = useReducedMotion(); const thumb = thumbSizes[size ?? "md"]; return ( <SwitchPrimitive.Root data-slot="switch" checked={checked} defaultChecked={defaultChecked} onCheckedChange={(value) => { setUncontrolled(value); onCheckedChange?.(value); }} onPointerDown={(event) => { setPressed(true); onPointerDown?.(event); }} onPointerUp={(event) => { setPressed(false); onPointerUp?.(event); }} onPointerLeave={(event) => { setPressed(false); onPointerLeave?.(event); }} className={cn(switchVariants({ size }), className)} {...props} > <SwitchPrimitive.Thumb asChild> <motion.span data-slot="switch-thumb" layout layoutDependency={`${on}-${pressed}`} transition={reduceMotion ? { duration: 0 } : spring.snappy} // A numeric radius lets motion keep the thumb round while it stretches style={{ borderRadius: 9999 }} className={cn( "pointer-events-none block bg-background shadow-sm data-[state=checked]:bg-primary-foreground", pressed ? thumb.pressed : thumb.rest, )} /> </SwitchPrimitive.Thumb> </SwitchPrimitive.Root> ); } export { Switch, switchVariants };Update the import paths to match your project.
Examples
Settings list
Each switch has a label and a hint linked with aria-describedby, grouped in a fieldset.
import { Switch } from "@/components/ui/switch";
import { useId } from "react";
const settings = [
{ key: "email", label: "Email updates", hint: "News and product changes, once a month.", on: true },
{ key: "security", label: "Security alerts", hint: "New sign-ins and password changes.", on: true },
{ key: "tips", label: "Tips", hint: "Short guides for features you haven't used.", on: false },
];
export default function SwitchSettings() {
const id = useId();
return (
<fieldset className="grid w-full max-w-sm gap-4">
<legend className="mb-2 text-sm font-medium">Notifications</legend>
{settings.map((setting) => (
<div key={setting.key} className="flex items-start justify-between gap-4">
<div className="grid gap-0.5">
<label htmlFor={`${id}-${setting.key}`} className="text-sm font-medium">
{setting.label}
</label>
<p id={`${id}-${setting.key}-hint`} className="text-sm text-muted-foreground">
{setting.hint}
</p>
</div>
<Switch
id={`${id}-${setting.key}`}
defaultChecked={setting.on}
aria-describedby={`${id}-${setting.key}-hint`}
/>
</div>
))}
</fieldset>
);
}Sizes
import { Switch } from "@/components/ui/switch";
export default function SwitchSizes() {
return (
<div className="flex items-center gap-6">
<Switch size="sm" aria-label="Small switch" defaultChecked />
<Switch aria-label="Medium switch" defaultChecked />
</div>
);
}Disabled
import { Switch } from "@/components/ui/switch";
import { useId } from "react";
export default function SwitchDisabled() {
const id = useId();
return (
<div className="flex items-center gap-3">
<Switch id={id} disabled defaultChecked />
<label htmlFor={id} className="text-sm font-medium text-muted-foreground">
Sync is managed by your organisation
</label>
</div>
);
}API reference
Also accepts every Radix Switch prop. The thumb glides on a spring and stretches while pressed.
| Prop | Type | Default | Description |
|---|---|---|---|
checked? | boolean | – | The on state, when you control it. |
defaultChecked? | boolean | false | The initial state. |
onCheckedChange? | (checked: boolean) => void | – | Called when the user turns it on or off. |
size? | "sm" | "md" | "md" | Track and thumb size. |
disabled? | boolean | false | Prevents interaction. |
name? | string | – | Submits the value with a form, through a hidden checkbox. |
Related components
- InputNew
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.
- SelectNew
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
- ButtonNew
An accessible React button with six variants, three sizes, icon support and a spring press, built on a native button.