ControlsCheckbox

A tick that draws itself in, on the Radix checkbox primitive.

Preview

The thinking

Forms need a checkbox, so the only question worth my time is what the tick does in the half-second after you click. The whole component is built around one belief: a check that draws is an acknowledgement, not a state flip — the box repeats your action back to you, the way a good waiter reads your order back. I leaned on Radix for the behaviour and spent the saved effort on the drawing, which is why the entire animation rides on a single stroke-dashoffset the browser interpolates — no motion/react, no JavaScript, no layout touched. The detail I'd defend to the end is that the indeterminate minus refuses to draw: it's the one state your click didn't author, so it fades instead, and that restraint is what keeps the draw meaning something. (A tick that always animates is just decoration with extra steps.)

Interface design

At rest the box is an outline and nothing more: h-4 w-4, rounded-sm, a border-input stroke on bg-transparent. Checked flips both fill and edge to primary — data-[state=checked]:bg-primary with a matching border-primary — and indeterminate gets exactly the same treatment, because "some of these are on" deserves the full fill; a parent that only half-committed visually would read as broken, not mixed. Both glyphs live in one 12-unit viewBox rendered at h-3 w-3, stroked at 1.5 with round caps in text-primary-foreground, so the tick and the minus look like two marks from the same pen. The root also ships the peer class, so a sibling label can restyle itself off the checkbox's state without any script. There are no variants and no sizes. A checkbox should be the same box everywhere you meet it.

Interaction

The whole animation is one property. The check's path measures about 10.6px, so stroke-dasharray: 11 hides the full stroke and checking transitions stroke-dashoffset from 11 to 0 over 200ms ease-swift — the tick draws tip to tail in pure CSS, no JavaScript, no layout. The indicator is force-mounted because a transition can't run from an element that didn't exist a frame ago. The box's fill moves faster, transition-colors duration-150, so the primary ground lands just before the stroke finishes drawing on top of it. And the two glyphs move differently on purpose: checked is a state your click authored, so it earns the draw; indeterminate is a state a parent computed on your behalf, so its minus only fades in over 150ms. The animation reads intent — it draws only what your hand drew.

Sound design

This file ships zero audio code, and that's the design. Radix gives the root role="checkbox", and the global sound layer plays the tap through one capturing click listener for anything carrying an interactive role — the checkbox speaks because of what it is, not because of code it wrote. Unlike Switch, which owns a paired on/off sound and marks itself data-silent so the layer steps aside, a checkbox gets the same tap in both directions; in my book checking and unchecking are the same size of decision. And because the layer skips anything matching :disabled, a disabled checkbox stays quiet — a control that won't act shouldn't sound like it did.

Empathy

Disabled is disabled:opacity-50 plus disabled:cursor-not-allowed — the box fades, the cursor explains why, and the sound layer's disabled check keeps it silent. Keyboard focus gets focus-visible:ring-1 on the ring token with the default outline removed, so the ring shows for a tabbing hand and never smears a mouse click. The svg is aria-hidden: a screen reader hears Radix's role="checkbox" and its state, never the drawing, so the animation is strictly a sighted-user flourish. And all three transitions — the box colours, the tick's draw, the minus's fade — carry motion-reduce:transition-none, so anyone who asked things to hold still gets an instant flip. The state change was always the message. The draw is just how I say it.
checkbox.tsx
"use client";

import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";

import { cn } from "@/lib/utils";

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

// the check's path measures ~10.6px — dasharray/dashoffset 11 hides the
// whole stroke, and the indicator stays force-mounted so the CSS
// transition always has a starting value to draw from
const CHECK_DRAW = "[stroke-dasharray:11] [stroke-dashoffset:11]";

// sound is delegated: radix gives the root role="checkbox", so the
// SoundLayer's tap covers it — zero audio code here.
const Checkbox = ({ ref, className, ...props }: CheckboxProps) => (
	<CheckboxPrimitive.Root
		ref={ref}
		className={cn(
			"group peer h-4 w-4 shrink-0 rounded-sm border border-input bg-transparent 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]:border-primary data-[state=checked]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary motion-reduce:transition-none",
			className
		)}
		{...props}
	>
		<CheckboxPrimitive.Indicator
			forceMount
			className="flex items-center justify-center text-primary-foreground"
		>
			<svg viewBox="0 0 12 12" fill="none" className="h-3 w-3" aria-hidden>
				{/* checked: the stroke draws in over 200ms, CSS only */}
				<path
					d="M2.5 6.5L5 9L9.5 3.5"
					stroke="currentColor"
					strokeWidth="1.5"
					strokeLinecap="round"
					strokeLinejoin="round"
					className={cn(
						CHECK_DRAW,
						"transition-[stroke-dashoffset] duration-200 ease-swift group-data-[state=checked]:[stroke-dashoffset:0] motion-reduce:transition-none"
					)}
				/>
				{/* indeterminate: not an action the user drew, so the minus
				    only fades — no draw */}
				<path
					d="M3 6h6"
					stroke="currentColor"
					strokeWidth="1.5"
					strokeLinecap="round"
					className="opacity-0 transition-opacity duration-150 ease-swift group-data-[state=indeterminate]:opacity-100 motion-reduce:transition-none"
				/>
			</svg>
		</CheckboxPrimitive.Indicator>
	</CheckboxPrimitive.Root>
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;

export { Checkbox };