← All components

Switch

A setting that takes effect at once, with a thumb that stretches while you hold it.

Large uploads wait until you are off mobile data.
Available on the Team plan.

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Switch — usage12 lines
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

PropNotes
checked / onCheckedChangeboolean / (checked) => voidControlled. Or `defaultChecked`.
label / descriptionReactNodeBeside the switch. Clicking the label flips it.
size"sm" | "md"16px and 20px tracks.
name / valuestringSubmits `value` under `name` while on, the way a checkbox does.
disabledbooleanDims 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.

packages/ui/src/components/forms/Switch.tsx134 lines
"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>
  );
}
packages/ui/src/lib/motion.ts36 lines
/**
 * 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 },
  };
}