ControlsToggle

A button that stays pressed — two variants and three sizes on cva.

Preview

The thinking

A toggle is the one button that doesn't spring back, so the whole design rests on making "held down" legible without a second glance. I treat the --secondary palette as a ladder and put each state on its own rung: rest is transparent ink in muted-foreground, hover steps onto plain --secondary, and data-[state=on] jumps a rung higher to bg-secondary-hover with full foreground. The load-bearing decision is that the gap between hover and on is structural, not cosmetic — hovering an unpressed toggle can never climb high enough to look pressed, so the two readings stay separable by construction. Everything else defers to that: the press is a fill rather than a border because a fill survives the squint a hairline loses, and the sound is borrowed from the switch because a thing that latches should latch audibly.

Interface design

Pressed must survive a glance: a fill reads at arm's length, a hairline doesn't, so on is bg-secondary-hover with full foreground ink — not a border trick. The unpressed label rests in muted-foreground, so gaining ink is part of the on-state too. The outline variant adds border border-input for toggles that have to hold a footprint before anyone touches them — a bare label like "Show grid" floats without an edge, where a bold B in a toolbar row doesn't. Three sizes (h-8, h-9, h-10) each carry a matching min-w, so an icon-only toggle bottoms out as a square instead of a sliver.

Interaction

One transition covers everything — duration-150 on ease-swift, shared by hover, press, and the latch, so the control has a single reflex instead of three. The press is active:scale-[0.98] and is the only scale in the component: a press puts real force on the surface and a hover puts none, so hover just recolors to bg-secondary while the compression waits for the finger. That gap is the intent-reading — pointing at a toggle is a question, pressing it is an answer, and the two must never feel alike. The latch itself gets no extra ceremony; data-[state=on] crosses to the higher rung inside the same 150ms, so on reads as a place you arrived, not an animation you watched. The forgiveness is inherited — radix renders a real <button>, so press, slide off, release: no latch.

Sound design

A toggle sounds like one — the same rising-on, falling-off pair as the switch, at the 0.3 ceiling every feedback sound shares. The root is data-silent so the SoundLayer's tap never doubles the pair, and the note is chosen from the value radix computes at commit, not from React state a spam-click could outrun. The toggle-group next door inherits this rule, but only in multiple mode — its single mode is a choice, not a switch, and choices tap.

Empathy

Radix renders this as a real <button> carrying aria-pressed, so the state a sighted user reads from the fill is the same state a screen reader announces — pressed never lives only in a background color. Keyboard users get the whole contract too: the pair hangs off onPressedChange rather than a pointer event, so a Space toggle sounds exactly like a click, and focus-visible:ring-1 draws the ring token for the keyboard where a mouse press never summons it. Disabled is disabled:opacity-50 with disabled:pointer-events-none, and because the button is truly disabled it leaves the tab order and the pair stays silent. Under motion-reduce:transition-none the rungs still change — they just snap. Long labels stay whole: whitespace-nowrap widens the toggle rather than wrapping it, which I'd rather have than a two-line B.
toggle.tsx
"use client";

import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";

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


// pressed is a fill (secondary-hover + full ink), hover is a rung below
// (secondary) — pressed must survive a glance, and hover must never
// impersonate it
const toggleVariants = cva(
	"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium text-muted-foreground transition duration-150 ease-swift hover:bg-secondary hover:text-secondary-foreground active:scale-[0.98] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-secondary-hover data-[state=on]:text-foreground motion-reduce:transition-none",
	{
		variants: {
			variant: {
				default: "bg-transparent",
				outline: "border border-input bg-transparent",
			},
			size: {
				default: "h-9 min-w-9 px-3",
				sm: "h-8 min-w-8 px-2.5",
				lg: "h-10 min-w-10 px-4",
			},
		},
		defaultVariants: {
			variant: "default",
			size: "default",
		},
	}
);

export interface ToggleProps
	extends React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root>,
		VariantProps<typeof toggleVariants> {
	ref?: React.Ref<React.ComponentRef<typeof TogglePrimitive.Root>>;
}

const Toggle = ({
	ref,
	className,
	variant,
	size,
	onPressedChange,
	...props
}: ToggleProps) => {
	// the toggle owns its pair (data-silent keeps the layer out). `next`
	// is computed by radix from the actual state being left at commit —
	// the dock's spam-click rule, not stale React state.
	const [playOn] = useSound(SOUNDS.toggleOn);
	const [playOff] = useSound(SOUNDS.toggleOff);

	return (
		<TogglePrimitive.Root
			ref={ref}
			data-silent
			onPressedChange={(next) => {
				(next ? playOn : playOff)();
				onPressedChange?.(next);
			}}
			className={cn(toggleVariants({ variant, size, className }))}
			{...props}
		/>
	);
};
Toggle.displayName = TogglePrimitive.Root.displayName;

export { Toggle, toggleVariants };