Components

Getting started

Components144

More

A switch with weight: the thumb stretches on press, is thrown across with a slight overshoot, and spins while an async save settles.

Used forSettings pagesFeature flagsTable rowsPricing

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/switch.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Settings pages

    Notification and privacy toggles that save as you flip them.

  • Feature flags

    Turning a flag on in an admin panel with the request in flight.

  • Table rows

    An Active column you can flip inline.

  • Pricing

    A compact on/off beside an option.

What it handles for you

An optimistic toggle needs pending state, rollback and an error signal, and most switches leave all three to you.

Instead of wiring upoptimistic toggle staterollback on errorloading indicator
  1. Async without wiring

    Return a promise from onCheckedChange: pending spinner, then settled or thrown back.

  2. Weight in the motion

    The thumb stretches 4px on press and is thrown with an overshoot (420ms).

  3. role="switch"

    aria-checked and aria-busy, Space and Enter.

  4. Label either side

    labelPosition="start" for settings rows with the switch on the right.

Overview

Settings switches usually flip instantly and lie when the save fails. This one flips at once, spins in its thumb while your promise runs, and throws itself back with a small shake if it rejects. Pressing stretches the thumb toward where it will go, and the track fills in step.

States

off / on
The thumb is thrown across (420ms, slight overshoot) and the primary fill fades in (260ms).
press
The thumb stretches 4px toward its next side.
pending
The thumb shows a spinner; the switch ignores presses.
failed
The thumb is thrown back and the track shakes (320ms).

Usage

usage.tsx
import { Switch } from "@/components/ui/switch";<Switch  label="Weekly digest"  description="A summary every Monday."  labelPosition="start"  defaultChecked  onCheckedChange={(on) => saveSetting("digest", on)}/>

Props

PropTypeDescription
checked / defaultCheckedbooleanState.
onCheckedChange(checked) => void | PromiseReturn a promise for the pending thumb; reject to put it back.
label / descriptionReactNodeText beside the switch.
labelPosition"start" | "end"default "end"Label before or after.
size"sm" | "md"default "md"16px or 20px tall.

Accessibility and motion

  • Reduced motion: the thumb moves at once and nothing shakes.
  • While pending, aria-busy is set and further presses are ignored.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/switch.tsx
"use client";import * as React from "react";import { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SWITCH: on and off, with weight * *   press    the thumb stretches toward the side it will go *   flip     it's thrown across with a slight overshoot while the *            track fills; the fill and thumb move as one *   async    if onCheckedChange returns a promise, the switch *            flips at once and the thumb spins until it settles; *            a rejection throws it back and shakes the track * ───────────────────────────────────────────────────────── */export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value"> {  checked?: boolean;  defaultChecked?: boolean;  /** Return a promise to show the pending thumb; throw to put it back. */  onCheckedChange?: (checked: boolean) => void | Promise<unknown>;  label?: React.ReactNode;  description?: React.ReactNode;  /** 16px or 20px tall. */  size?: "sm" | "md";  /** Put the label before the switch (settings rows). */  labelPosition?: "start" | "end";  ref?: React.Ref<HTMLButtonElement>;}const SIZES = { sm: { w: 28, h: 16, pad: 2 }, md: { w: 36, h: 20, pad: 2 } };export function Switch({  checked,  defaultChecked = false,  onCheckedChange,  label,  description,  size = "md",  labelPosition = "end",  disabled,  className = "",  id,  ref,  ...props}: SwitchProps) {  const reduced = useInteractionReducedMotion();  const auto = React.useId();  const switchId = id ?? auto;  const track = React.useRef<HTMLSpanElement>(null);  const [own, setOwn] = React.useState(defaultChecked);  const [optimistic, setOptimistic] = React.useState<boolean | null>(null);  const [pressed, setPressed] = React.useState(false);  const on = optimistic ?? (checked !== undefined ? checked : own);  const pending = optimistic !== null;  const s = SIZES[size];  const thumb = s.h - s.pad * 2;  const travel = s.w - thumb - s.pad * 2;  const stretch = pressed && !reduced ? 4 : 0;  const toggle = () => {    if (disabled || pending) return;    const next = !on;    const result = onCheckedChange?.(next);    if (result && typeof (result as Promise<unknown>).then === "function") {      setOptimistic(next);      (result as Promise<unknown>).then(        () => {          if (checked === undefined) setOwn(next);          setOptimistic(null);        },        () => {          setOptimistic(null);          if (!reduced) track.current?.animate([{ transform: "none" }, { transform: "translateX(-3px)" }, { transform: "translateX(3px)" }, { transform: "none" }], { duration: 320, easing: "ease-out" });        },      );    } else if (checked === undefined) setOwn(next);  };  const control = (    <button      ref={ref}      id={switchId}      type="button"      role="switch"      aria-checked={on}      aria-busy={pending || undefined}      disabled={disabled}      onClick={toggle}      onPointerDown={() => setPressed(true)}      onPointerUp={() => setPressed(false)}      onPointerLeave={() => setPressed(false)}      onBlur={() => setPressed(false)}      className={`relative inline-flex shrink-0 rounded-full cursor-pointer disabled:cursor-default disabled:opacity-50 ${INTERACTION_FOCUS}`}      style={{ width: s.w, height: s.h }}      {...props}    >      <span ref={track} className="absolute inset-0 overflow-hidden rounded-full bg-muted shadow-[inset_0_0_0_1px_var(--border)]">        <span          className="absolute inset-0 rounded-full bg-primary"          style={{ opacity: on ? 1 : 0, transition: reduced ? "none" : `opacity 260ms ${EASE_OUT}` }}        />      </span>      <span        className="absolute rounded-full bg-background shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]"        style={{          top: s.pad,          left: s.pad,          height: thumb,          width: thumb + stretch,          // Stretching toward where it's going: grow from the far edge when on.          transform: `translateX(${on ? travel - stretch : 0}px)`,          transition: reduced ? "none" : `transform 420ms ${EASE_THROW}, width 260ms ${EASE_OUT}`,        }}      >        <span className="absolute inset-0 flex items-center justify-center text-primary" style={interactionIcon(pending, reduced)}>          <span className={`size-[60%] rounded-full border-[1.5px] border-current border-t-transparent ${pending ? "animate-spin" : ""}`} />        </span>      </span>    </button>  );  if (!label && !description) return <span className={className}>{control}</span>;  return (    <div className={`flex items-start gap-3 ${labelPosition === "start" ? "flex-row-reverse justify-between" : ""} ${className}`}>      <span className="mt-0.5">{control}</span>      <label htmlFor={switchId} className={`flex min-w-0 flex-col gap-0.5 ${disabled ? "opacity-50" : "cursor-pointer"}`}>        {label && <span className="text-[13.5px] font-medium leading-5 text-foreground">{label}</span>}        {description && <span className="text-[12.5px] leading-snug text-muted-foreground">{description}</span>}      </label>    </div>  );}export default Switch;

Registry item: /r/switch.json