ControlsSwitch

A binary toggle with a sprung thumb, on the Radix switch primitive.

Preview

The thinking

A switch is a physical flip, so I wanted exactly one thing on it to feel like it has mass — the thumb — and everything else to stay mechanical. That's the load-bearing decision: the thumb travels on a spring while the track color crossfades on a flat 150ms ease-swift and the focus ring just appears. I didn't reinvent the state machine either; Radix stays the source of truth and I only mirror checked in JS so the spring has a value to animate toward, which is why a controlled parent that rejects a change can never leave the two stores disagreeing. The detail I care about is that the thumb borrows the exact spring the dock uses to magnify icons, so a toggle here moves like it came from the same hand as everything else with weight.

Interface design

The whole control is two colors and a circle. The track is h-5 w-9 — 36 by 20 — and the state lives entirely in its fill: bg-input resting, data-[state=checked]:bg-primary on, no icons, no labels, no border swap. The thumb is an h-4 w-4 circle on bg-card, so it reads as a piece of card surface riding the track in either theme, and the travel constants give it the same 2px inset at both ends — THUMB_REST is 2, THUMB_CHECKED is 18, out of 36. shrink-0 refuses the squeeze in a cramped flex row, because a squashed pill stops reading as a switch, and the root is a peer so a sibling label can restyle itself off the switch's state without any new wiring.

Interaction

The thumb is the one place in this wave where physics earns its keep. A bezier curve arrives politely; a thing you flick should carry momentum and settle, so the thumb animates on SPRING.snappy — mass .1, stiffness 120, damping 12, the same constants as the dock's magnification spring. One spring for the whole site means everything with mass moves like it came from the same factory. useReducedMotion swaps the spring for a 150ms ease-swift slide, and motion-reduce:transition-none strips even that to an instant flip.

Sound design

A toggle sounds like one: a rising third turning on, falling back off — the rule the dock's theme switch established. The root is data-silent so the SoundLayer's tap never lands on top of the pair. Which note plays is derived from the state the switch is actually leaving at commit time, via onCheckedChange — spam-clicking can outrun React state, but it can't outrun the state Radix computes the flip from, so the sound and the motion never desync.

Empathy

Disabled fades the whole control to disabled:opacity-50 and shows disabled:cursor-not-allowed, so the cursor states the refusal instead of the switch just going dead; and because Radix renders a real button underneath, disabling it also drops it from the tab order on its own. Screen readers get role="switch" with aria-checked from the Radix root, so the control announces both what it is and which side it's on. Keyboard focus draws focus-visible:ring-1 on the ring token; a mouse click never summons it. Reduced motion is answered twice, as the interaction notes say — and motion-reduce:transition-none sits on the track too, so the color flip goes instant along with the thumb. And the thumb is pointer-events-none, so the full 36-by-20 pill is one hit target — press the thumb, the track, or either end, and the click lands. Nobody should have to aim at a 16px circle.
switch.tsx
"use client";

import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { SPRING } from "@/craft/lib/motion";
import { useSound } from "@/hooks/useSound";
import { SOUNDS } from "@/lib/sounds";


export interface SwitchProps
	extends React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> {
	ref?: React.Ref<React.ComponentRef<typeof SwitchPrimitive.Root>>;
}

// track geometry: 36×20 track, 16px thumb — rest x 2, checked x 18,
// so the inset is 2px on both ends
const THUMB_REST = 2;
const THUMB_CHECKED = 18;

const Switch = ({
	ref,
	className,
	checked,
	defaultChecked,
	onCheckedChange,
	...props
}: SwitchProps) => {
	const reduceMotion = useReducedMotion();

	// the spring needs the checked state in JS, so we mirror it — radix
	// stays the source of truth and hands us the next value at commit time
	const [internalChecked, setInternalChecked] = React.useState(
		defaultChecked ?? false
	);
	const isChecked = checked ?? internalChecked;

	// the switch owns its pair (data-silent below keeps the layer out).
	// `next` is computed by radix from the actual state the switch is
	// leaving — the dock's spam-click rule, not stale React state.
	const [playOn] = useSound(SOUNDS.toggleOn);
	const [playOff] = useSound(SOUNDS.toggleOff);

	const handleCheckedChange = (next: boolean) => {
		// controlled parents own the value — the mirror only tracks
		// uncontrolled use, so a parent that rejects the change can't
		// leave the two stores disagreeing
		if (checked === undefined) setInternalChecked(next);
		(next ? playOn : playOff)();
		onCheckedChange?.(next);
	};

	return (
		<SwitchPrimitive.Root
			ref={ref}
			data-silent
			checked={isChecked}
			onCheckedChange={handleCheckedChange}
			className={cn(
				"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-input transition-colors duration-150 ease-swift focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary motion-reduce:transition-none",
				className
			)}
			{...props}
		>
			{reduceMotion ? (
				// reduced motion: same positions, a 150ms ease-swift slide —
				// and motion-reduce strips even that for an instant flip
				<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 translate-x-0.5 rounded-full bg-card transition-transform duration-150 ease-swift data-[state=checked]:translate-x-[18px] motion-reduce:transition-none" />
			) : (
				<SwitchPrimitive.Thumb asChild>
					<motion.span
						className="pointer-events-none block h-4 w-4 rounded-full bg-card"
						initial={false}
						animate={{ x: isChecked ? THUMB_CHECKED : THUMB_REST }}
						transition={SPRING.snappy}
					/>
				</SwitchPrimitive.Thumb>
			)}
		</SwitchPrimitive.Root>
	);
};
Switch.displayName = SwitchPrimitive.Root.displayName;

export { Switch };