Skip to content
Uxelith
Sign in
Freev1.0.0Forms

Switch

An on/off switch with a springy thumb you can also drag, like iOS. Loading state, optional check/cross icons, label, description and form support built in.

pnpm dlx shadcn add …/r/switch.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/switch.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as switch.tsx. It's yours: change anything.

Usage

example.tsx
import { Switch } from "@/components/switch"

export function Settings() {
  return <Switch label="Email notifications" description="Weekly summary." defaultChecked />
}

Source

One file, 205 lines, nothing hidden. Read it before you install it.

switch.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

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

export interface SwitchProps extends Omit<React.ComponentProps<"button">, "onChange" | "value"> {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  /** Visible label beside the switch. Without one, pass `aria-label`. */
  label?: React.ReactNode;
  /** Helper text under the label, linked with `aria-describedby`. */
  description?: React.ReactNode;
  size?: "sm" | "md";
  /** Submit as a form field with this name ("on" when checked, like a checkbox). */
  name?: string;
  /** Shows a spinner in the thumb and ignores input, e.g. while a change saves. */
  loading?: boolean;
  /** Draws a check or a cross in the thumb, so state doesn't rely on colour. */
  icons?: boolean;
}

const sizes = {
  sm: { track: "h-5 w-9", thumb: 16, travel: 16, icon: "size-2.5" },
  md: { track: "h-6 w-11", thumb: 20, travel: 20, icon: "size-3" },
} as const;

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const STRETCH = 5;
const DRAG_START = 3;

/**
 * An on/off switch with a springy thumb you can also drag, like iOS.
 *
 * - `role="switch"` with `aria-checked`; Space and Enter toggle it.
 * - Pressing stretches the thumb toward where it will go; dragging it past
 *   halfway and letting go sets that side.
 * - A visible `label` is a real `<label>`, so clicking it toggles too, and
 *   `description` is linked with `aria-describedby`.
 * - `loading` sets `aria-busy`, shows a spinner and ignores input.
 * - With `name`, a hidden input submits it with forms.
 * - Colours: `--switch-on` (default `--primary`), `--switch-thumb` (white).
 *   No travel under reduced motion.
 */
export function Switch({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  label,
  description,
  size = "md",
  name,
  loading = false,
  icons = false,
  id: idProp,
  disabled,
  className,
  onClick,
  ...props
}: SwitchProps) {
  const generatedId = React.useId();
  const id = idProp ?? generatedId;
  const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
  const checked = checkedProp ?? uncontrolled;
  const [pressed, setPressed] = React.useState(false);
  const [dragX, setDragX] = React.useState<number | null>(null);
  const drag = React.useRef<{ startX: number; from: number; moved: boolean } | null>(null);
  const suppressClick = React.useRef(false);
  const reduceMotion = useReducedMotion();
  const s = sizes[size];

  const set = (next: boolean) => {
    if (next === checked) return;
    if (checkedProp === undefined) setUncontrolled(next);
    onCheckedChange?.(next);
  };

  const restX = checked ? s.travel : 0;
  const stretched = pressed && dragX === null && !reduceMotion;
  const x = dragX ?? (stretched && checked ? restX - STRETCH : restX);
  const inert = disabled || loading;

  const button = (
    <button
      id={id}
      type="button"
      role="switch"
      aria-checked={checked}
      aria-busy={loading || undefined}
      aria-describedby={description ? `${id}-description` : undefined}
      disabled={disabled}
      onClick={(event) => {
        onClick?.(event);
        if (suppressClick.current) {
          suppressClick.current = false;
          return;
        }
        if (event.defaultPrevented || loading) return;
        set(!checked);
      }}
      onPointerDown={(event) => {
        if (inert || event.button !== 0) return;
        setPressed(true);
        event.currentTarget.setPointerCapture?.(event.pointerId);
        drag.current = { startX: event.clientX, from: restX, moved: false };
      }}
      onPointerMove={(event) => {
        const d = drag.current;
        if (!d) return;
        const dx = event.clientX - d.startX;
        if (!d.moved && Math.abs(dx) < DRAG_START) return;
        d.moved = true;
        setDragX(Math.min(s.travel, Math.max(0, d.from + dx)));
      }}
      onPointerUp={() => {
        const d = drag.current;
        drag.current = null;
        setPressed(false);
        if (d?.moved && dragX !== null) {
          suppressClick.current = true;
          set(dragX > s.travel / 2);
        }
        setDragX(null);
      }}
      onPointerCancel={() => {
        drag.current = null;
        setPressed(false);
        setDragX(null);
      }}
      className={cn(
        "relative inline-flex shrink-0 cursor-pointer touch-none select-none items-center rounded-full border p-px outline-none",
        "transition-[background-color,border-color,box-shadow] duration-200 motion-reduce:transition-none",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress",
        checked
          ? "border-transparent bg-[var(--switch-on,var(--primary))] shadow-[inset_0_1px_2px_rgb(0_0_0/0.15)]"
          : "border-input bg-muted shadow-[inset_0_1px_2px_rgb(0_0_0/0.12)]",
        s.track,
        !label && className,
      )}
      {...props}
    >
      <motion.span
        aria-hidden="true"
        initial={false}
        animate={{ x, width: stretched ? s.thumb + STRETCH : s.thumb }}
        transition={dragX !== null || reduceMotion ? { duration: 0 } : pressed ? SPRING_SNAPPY : SPRING_SMOOTH}
        style={{ height: s.thumb }}
        className="flex items-center justify-center rounded-full bg-[var(--switch-thumb,white)] shadow-[0_1px_3px_rgb(0_0_0/0.3),0_0_0_0.5px_rgb(0_0_0/0.08)]"
      >
        {loading ? (
          <svg viewBox="0 0 16 16" className={cn(s.icon, "animate-spin text-neutral-500 motion-reduce:animate-none")}>
            <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="26 12" strokeLinecap="round" />
          </svg>
        ) : (
          icons && (
            <svg
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.25"
              strokeLinecap="round"
              strokeLinejoin="round"
              className={cn(s.icon, checked ? "text-[var(--switch-on,var(--primary))]" : "text-neutral-400")}
            >
              {/* The icon on first render doesn't draw itself, so server and client agree. */}
              <AnimatePresence initial={false} mode="popLayout">
                <motion.path
                  key={checked ? "on" : "off"}
                  d={checked ? "M3.5 8.5 6.5 11.5 12.5 4.5" : "M4.5 4.5l7 7M11.5 4.5l-7 7"}
                  initial={{ pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  exit={{ opacity: 0, transition: { duration: 0 } }}
                  transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
                />
              </AnimatePresence>
            </svg>
          )
        )}
      </motion.span>
      {name && <input type="hidden" name={name} value={checked ? "on" : ""} disabled={disabled} />}
    </button>
  );

  if (!label) return button;
  return (
    <div className={cn("flex items-start justify-between gap-4", className)}>
      <div className="grid gap-0.5">
        <label htmlFor={id} className={cn("text-sm font-medium", disabled ? "opacity-50" : "cursor-pointer")}>
          {label}
        </label>
        {description && (
          <p id={`${id}-description`} className="text-sm text-muted-foreground">
            {description}
          </p>
        )}
      </div>
      {button}
    </div>
  );
}

Props

PropTypeDefaultDescription
checked / defaultCheckedboolean— / falseControlled or uncontrolled state.
onCheckedChange(checked: boolean) => void—Called with the new state.
labelReactNode—Visible label; clicking it toggles. Without it, pass aria-label.
descriptionReactNode—Helper text linked with aria-describedby.
size"sm" | "md""md"Track size.
namestring—Submit with forms as "on" when checked.
loadingbooleanfalseSpinner in the thumb, aria-busy, ignores input — e.g. while a change saves.
iconsbooleanfalseA check or cross in the thumb, so state doesn't rely on colour.
--switch-onCSS variable--primaryTrack colour when on.

Accessibility

  • role="switch" with aria-checked; Space and Enter toggle it.
  • The visible label is a real <label> for the switch.
  • Description is linked with aria-describedby.
  • loading sets aria-busy and ignores input.
  • icons show state without relying on colour.
  • No travel under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Forms

All 10 in Forms
  • FormsFree

    Animated Input

    A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.