Switch
A setting that takes effect at once, with a thumb that stretches while you hold it.
The idea
The thumb slides on the shared spring, and while held it stretches a quarter wider toward the way it is about to go — squash and stretch, so the press reads as loading a spring the release lets go. It is a scale with the far edge pinned, so nothing is laid out per frame. On, the track fills with ink; off, it is a grey well, so position and fill both say which it is. It is role="switch", announced as on or off rather than as a checkbox.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Switch } from "mihirmodi-ui";
// A switch takes effect at once. If the change waits for a Save
// button, it is a checkbox.
<Switch
label="Sync over Wi-Fi only"
description="Large uploads wait until you are off mobile data."
checked={wifiOnly}
onCheckedChange={setWifiOnly}
/>
<Switch size="sm" label="Compact rows" defaultChecked />Props
| Prop | Type | Notes |
|---|---|---|
| checked / onCheckedChangeboolean / (checked) => void | boolean / (checked) => void | Controlled. Or `defaultChecked`. |
| label / descriptionReactNode | ReactNode | Beside the switch. Clicking the label flips it. |
| size"sm" | "md" | "sm" | "md"= "md" | 16px and 20px tracks. |
| name / valuestring | string= value "on" | Submits `value` under `name` while on, the way a checkbox does. |
| disabledboolean | boolean | Dims it and refuses presses. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useId, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, SLIDE } from "../../lib/motion.js";
export type SwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "defaultChecked" | "children"> & {
/** Controlled. */
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
/** The text beside the switch. Clicking it flips the switch. */
label?: ReactNode;
/** A quieter line under the label, linked as the switch's description. */
description?: ReactNode;
size?: "sm" | "md";
/** Submit `value` under this name while on, the way a checkbox does. */
name?: string;
value?: string;
};
/* Track width, thumb size, the 2px inset, and how far the thumb stretches
while held. Travel is what is left of the track once the thumb is in it. */
const SIZES = {
sm: { track: "h-4 w-7", thumb: "h-3 w-3", travel: 12, stretch: 3, w: 12 },
md: { track: "h-5 w-9", thumb: "h-4 w-4", travel: 16, stretch: 4, w: 16 },
} as const;
/**
* An on/off setting that takes effect at once — a switch, not a checkbox,
* because nothing waits for a Save.
*
* The thumb slides on the package's shared spring, and while it is held it
* stretches a quarter wider toward the way it is about to travel: squash and
* stretch, so the press reads as loading a spring that the release lets go.
* The stretch is a scale with the thumb re-centred to keep its far edge
* pinned, so nothing is laid out per frame. On, the track fills with ink and
* the thumb sits on it as the raised highlight surface; off, the track is a
* grey well. Two signals — position and fill — and neither is a colour step.
*
* It is a `button` with `role="switch"` and `aria-checked`, so it is announced
* as a switch that is on or off rather than as a checkbox. Space and Enter flip
* it, and Space held shows the same stretch a held pointer does.
*/
export function Switch({
checked,
defaultChecked = false,
onCheckedChange,
label,
description,
size = "md",
name,
value = "on",
disabled,
id,
className = "",
onClick,
...rest
}: SwitchProps) {
const reduce = useReducedMotion();
const auto = useId();
const btnId = id ?? auto;
const descId = `${btnId}-desc`;
const [inner, setInner] = useState(defaultChecked);
const on = checked !== undefined ? checked : inner;
const [held, setHeld] = useState(false);
const s = SIZES[size];
const half = s.stretch / 2;
const stretch = held && !reduce;
const x = on ? s.travel - (stretch ? half : 0) : stretch ? half : 0;
const flip = () => {
if (checked === undefined) setInner(!on);
onCheckedChange?.(!on);
};
const control = (
<button
type="button"
role="switch"
id={btnId}
aria-checked={on}
aria-describedby={description ? descId : undefined}
disabled={disabled}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) flip();
}}
onPointerDown={(e) => e.button === 0 && setHeld(true)}
onPointerUp={() => setHeld(false)}
onPointerLeave={() => setHeld(false)}
onPointerCancel={() => setHeld(false)}
onKeyDown={(e) => e.key === " " && setHeld(true)}
onKeyUp={() => setHeld(false)}
onBlur={() => setHeld(false)}
className={`relative inline-flex shrink-0 items-center rounded-full p-[2px] transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none ${s.track} ${
on
? "bg-ink enabled:hover:bg-[color-mix(in_srgb,var(--color-ink)_86%,var(--color-background-100))]"
: "bg-gray-alpha-300 shadow-[inset_0_0_0_0.5px_var(--color-gray-alpha-400)]"
}`}
{...rest}
>
<LazyMotion features={domAnimation} strict>
<m.span
aria-hidden
className={`block rounded-full bg-highlight shadow-[0_1px_2px_rgba(0,0,0,0.16),0_0_0_0.5px_var(--color-gray-alpha-400)] ${s.thumb}`}
initial={false}
animate={{ x, scaleX: stretch ? (s.w + s.stretch) / s.w : 1 }}
transition={reduce ? FLAT : SLIDE}
/>
</LazyMotion>
{name && on && <input type="hidden" name={name} value={value} />}
</button>
);
if (!label) return <span className={`inline-flex ${className}`}>{control}</span>;
return (
<div className={`inline-flex items-start gap-3 ${className}`}>
<span className={`flex ${size === "md" ? "mt-0" : "mt-0.5"}`}>{control}</span>
<span className={`flex min-w-0 flex-col gap-0.5 ${disabled ? "opacity-50" : ""}`}>
<label htmlFor={btnId} className={`select-none text-[14px] leading-5 text-gray-1000 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
{label}
</label>
{description && (
<span id={descId} className="text-[12px] leading-[18px] text-muted">
{description}
</span>
)}
</span>
</div>
);
}
/**
* The motion presets the newer components share. The older ones (Dock,
* DropMenu, HighlightList) carry their own copies of the same numbers, which is
* deliberate: each of them documents why its curve is what it is, in place.
*/
/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;
/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;
/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;
/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
return {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
};
}