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.
Install
npx shadcn@latest add https://tcosta.dev/r/switch.jsonOr 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';