Talisson Costa
← All components

Switch

Springy, draggable thumb that stretches while pressed. Async (optimistic or pessimistic) with shake-on-error, icons, custom color and native form support.

  • layout
  • drag
  • whileTap variants
  • keyframes
  • AnimatePresence popLayout
  • a11y: switch role
  • forms

With icons. Try dragging the thumb.

Async, optimistic: moves now, saves in the background.

Async, pessimistic — and the server always fails.

Custom --switch-on color.

Disabled on this plan.

Native form: submit / reset

Install

npx shadcn@latest add https://tcosta.dev/r/switch.json

Or copy the files below into components/switch/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion + class-variance-authority.

Source

switch.tsx
'use client';

import { motion, type HTMLMotionProps } from 'motion/react';
import type { MouseEvent, PointerEvent, ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { SwitchInput } from './switch-input';
import { SwitchThumb } from './switch-thumb';
import { useSwitchState } from './use-switch-state';
import { useThumbDrag } from './use-thumb-drag';
import { shakeVariants, thumbSizes, trackVariants, type SwitchSize } from './variants';

type SwitchProps = Omit<
  HTMLMotionProps<'button'>,
  | 'onChange'
  | 'value'
  | 'children'
  | 'initial'
  | 'animate'
  | 'whileTap'
  | 'variants'
  | 'name'
  | 'form'
> & {
  checked?: boolean;
  defaultChecked?: boolean;
  /** Return a promise to make the change async: spinner while pending, shake and revert on reject. */
  onCheckedChange?: (checked: boolean) => void | Promise<unknown>;
  /** Async only: "optimistic" moves the thumb right away, "pessimistic" waits for the promise. */
  mode?: 'optimistic' | 'pessimistic';
  loading?: boolean;
  size?: SwitchSize;
  icons?: { checked?: ReactNode; unchecked?: ReactNode };
  name?: string;
  value?: string;
  required?: boolean;
  form?: string;
};

export function Switch({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  mode = 'optimistic',
  loading = false,
  size = 'md',
  icons,
  name,
  value = 'on',
  required,
  form,
  disabled,
  className,
  onClick,
  onPointerDown,
  ...props
}: SwitchProps) {
  const { checked, busy, error, change, reset } = useSwitchState({
    checked: checkedProp,
    defaultChecked,
    onCheckedChange,
    mode,
    loading,
    disabled,
  });
  const { dragProps, wasDragged, resetDrag } = useThumbDrag({
    checked,
    travel: thumbSizes[size].travel,
    onToggle: () => change(!checked),
  });

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (!wasDragged() && !event.defaultPrevented) change(!checked);
  };

  const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
    resetDrag();
    onPointerDown?.(event);
  };

  return (
    <span className="relative inline-flex">
      <motion.button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-busy={busy || undefined}
        disabled={disabled}
        data-state={checked ? 'checked' : 'unchecked'}
        data-error={error || undefined}
        initial={false}
        variants={shakeVariants}
        animate={error ? 'error' : 'idle'}
        whileTap={busy ? undefined : 'pressed'}
        onClick={handleClick}
        onPointerDown={handlePointerDown}
        className={cn(trackVariants({ size, checked }), className)}
        {...props}
      >
        <SwitchThumb
          size={size}
          checked={checked}
          busy={busy}
          draggable={!busy && !disabled}
          icon={checked ? icons?.checked : icons?.unchecked}
          dragProps={dragProps}
        />
      </motion.button>

      {(name !== undefined || required) && (
        <SwitchInput
          name={name}
          value={value}
          form={form}
          required={required}
          checked={checked}
          disabled={disabled}
          onReset={checkedProp === undefined ? reset : () => {}}
        />
      )}
    </span>
  );
}
use-switch-state.ts
import { useEffect, useState } from 'react';

type UseSwitchStateOptions = {
  checked?: boolean;
  defaultChecked: boolean;
  onCheckedChange?: (checked: boolean) => void | Promise<unknown>;
  mode: 'optimistic' | 'pessimistic';
  loading: boolean;
  disabled?: boolean;
};

const isPromise = (value: unknown): value is Promise<unknown> =>
  typeof (value as Promise<unknown>)?.then === 'function';

/** Controlled/uncontrolled state, plus async changes that show `busy` and revert with `error`. */
export function useSwitchState({
  checked: checkedProp,
  defaultChecked,
  onCheckedChange,
  mode,
  loading,
  disabled,
}: UseSwitchStateOptions) {
  const [uncontrolled, setUncontrolled] = useState(defaultChecked);
  const [pending, setPending] = useState<boolean | null>(null);
  const [error, setError] = useState(false);

  const isControlled = checkedProp !== undefined;
  const committed = checkedProp ?? uncontrolled;
  const checked = mode === 'optimistic' && pending !== null ? pending : committed;
  const busy = loading || pending !== null;

  // Clear the error once the shake has played.
  useEffect(() => {
    if (!error) return;
    const timeout = setTimeout(() => setError(false), 700);
    return () => clearTimeout(timeout);
  }, [error]);

  const commit = (next: boolean) => {
    if (!isControlled) setUncontrolled(next);
  };

  const change = async (next: boolean) => {
    if (busy || disabled) return;
    setError(false);

    const result = onCheckedChange?.(next);
    if (!isPromise(result)) return commit(next);

    setPending(next);
    try {
      await result;
      commit(next);
    } catch {
      setError(true);
    } finally {
      setPending(null);
    }
  };

  const reset = () => commit(defaultChecked);

  return { checked, busy, error, change, reset };
}
use-thumb-drag.ts
import type { PanInfo } from 'motion/react';
import { useRef } from 'react';

type UseThumbDragOptions = {
  checked: boolean;
  travel: number;
  onToggle: () => void;
};

/** Dragging the thumb past half its travel toggles the switch. */
export function useThumbDrag({ checked, travel, onToggle }: UseThumbDragOptions) {
  const dragged = useRef(false);

  const dragProps = {
    dragConstraints: checked ? { left: -travel, right: 0 } : { left: 0, right: travel },
    onDragStart: () => {
      dragged.current = true;
    },
    onDragEnd: (_: unknown, info: PanInfo) => {
      const isPastHalf = checked ? info.offset.x < -travel / 2 : info.offset.x > travel / 2;
      if (isPastHalf) onToggle();
    },
  };

  return {
    dragProps,
    // A drag also ends in a click, which must be ignored: the drag already decided.
    wasDragged: () => dragged.current,
    resetDrag: () => {
      dragged.current = false;
    },
  };
}
switch-thumb.tsx
import { AnimatePresence, motion, type DragHandler } from 'motion/react';
import type { ReactNode } from 'react';
import { Spinner } from './spinner';
import {
  fadeTransition,
  iconVariants,
  thumbClassName,
  thumbSizes,
  thumbTransition,
  type SwitchSize,
} from './variants';

type SwitchThumbProps = {
  size: SwitchSize;
  checked: boolean;
  busy: boolean;
  draggable: boolean;
  icon?: ReactNode;
  dragProps: {
    dragConstraints: { left: number; right: number };
    onDragStart: () => void;
    onDragEnd: DragHandler;
  };
};

export function SwitchThumb({ size, checked, busy, draggable, icon, dragProps }: SwitchThumbProps) {
  const { thumb, stretched } = thumbSizes[size];
  const iconSize = thumb - 8;

  return (
    <motion.span
      layout
      transition={thumbTransition}
      variants={{ idle: { width: thumb }, pressed: { width: stretched } }}
      style={{ height: thumb }}
      drag={draggable ? 'x' : false}
      dragElastic={0.08}
      dragMomentum={false}
      dragSnapToOrigin
      {...dragProps}
      className={thumbClassName}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {(busy || icon) && (
          <motion.span
            key={busy ? 'spinner' : String(checked)}
            variants={iconVariants}
            initial="hidden"
            animate="visible"
            exit="hidden"
            transition={fadeTransition}
            className="flex [&>svg]:size-full"
            style={{ width: iconSize, height: iconSize }}
          >
            {busy ? <Spinner size={iconSize} /> : icon}
          </motion.span>
        )}
      </AnimatePresence>
    </motion.span>
  );
}
switch-input.tsx
import { useEffect, useRef } from 'react';

type SwitchInputProps = {
  name?: string;
  value: string;
  form?: string;
  required?: boolean;
  checked: boolean;
  disabled?: boolean;
  onReset: () => void;
};

/** Hidden native checkbox, so the switch submits, validates (`required`) and resets with its form. */
export function SwitchInput({ onReset, ...props }: SwitchInputProps) {
  const input = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const form = input.current?.form;
    form?.addEventListener('reset', onReset);
    return () => form?.removeEventListener('reset', onReset);
  }, [onReset]);

  return (
    <input
      ref={input}
      type="checkbox"
      aria-hidden
      tabIndex={-1}
      // Not readOnly: that would exclude it from `required` validation.
      onChange={() => {}}
      className="pointer-events-none absolute inset-0 m-0 size-full opacity-0"
      {...props}
    />
  );
}
variants.ts
import { cva } from 'class-variance-authority';
import type { Transition, Variants } from 'motion/react';

// Colors are overridable with --switch-on / --switch-thumb.
export const trackVariants = cva(
  [
    'group relative inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 outline-none',
    'transition-[background-color,box-shadow] duration-200 ease-out',
    'bg-input data-[state=unchecked]:hover:bg-muted-foreground/30',
    'data-[state=checked]:bg-[var(--switch-on,var(--color-primary))]',
    'focus-visible:ring-[3px] focus-visible:ring-ring/50',
    'data-error:ring-2 data-error:ring-destructive/60',
    'disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress',
  ],
  {
    variants: {
      size: {
        sm: 'h-5 w-9',
        md: 'h-6 w-11',
      },
      checked: {
        true: 'justify-end',
        false: 'justify-start',
      },
    },
  },
);

export const thumbClassName = [
  'flex items-center justify-center rounded-full shadow-[0_1px_3px_rgb(0_0_0/0.2)]',
  'bg-[var(--switch-thumb,var(--color-background))]',
  'dark:group-data-[state=unchecked]:bg-[var(--switch-thumb,var(--color-foreground))]',
  'group-data-[state=checked]:bg-[var(--switch-thumb,var(--color-primary-foreground))]',
  'text-muted-foreground group-data-[state=checked]:text-[var(--switch-on,var(--color-primary))]',
].join(' ');

// Pixel sizes drive the motion values (width while pressed, drag distance).
export const thumbSizes = {
  sm: { thumb: 16, stretched: 20, travel: 16 },
  md: { thumb: 20, stretched: 25, travel: 20 },
};

export type SwitchSize = keyof typeof thumbSizes;

// A little overshoot so the thumb "lands" instead of stopping dead.
export const thumbTransition: Transition = { type: 'spring', duration: 0.35, bounce: 0.3 };
export const fadeTransition: Transition = { type: 'spring', duration: 0.25, bounce: 0 };

export const shakeVariants: Variants = {
  idle: { x: 0 },
  error: { x: [0, -5, 5, -3, 3, 0], transition: { duration: 0.4, ease: 'easeInOut' } },
};

export const iconVariants: Variants = {
  hidden: { opacity: 0, scale: 0.4, filter: 'blur(2px)' },
  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },
};
spinner.tsx
export function Spinner({ size }: { size: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      className="animate-spin"
      aria-hidden
    >
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}
index.ts
export { Switch } from './switch';