Checkbox Check Draw
The box fills from its center and the tick strokes itself in over the fill.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Checkbox Check Draw
*
* The box fills from its center and the tick strokes itself in behind
* the fill, so the two read as one gesture rather than a swap. A real
* checkbox: `role="checkbox"`, Space to toggle, label click included.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so the row reads correctly on a
* light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `hint`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CheckboxCheckDrawProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The row's text. Also the control's accessible name. */
label?: string;
/** Optional second line under the label. */
hint?: string;
/** Starting state. */
defaultChecked?: boolean;
/** Fill color of the marked box. */
accent?: string;
/** Renders the row inert and dimmed. */
disabled?: boolean;
/** Fires with the new state on every toggle. */
onCheckedChange?: (checked: boolean) => void;
};
type VariantConfig = {
/** Grows the accent square out of the empty box. */
fill: { type: "spring"; stiffness: number; damping: number };
/** Seconds the tick takes to stroke across. */
stroke: number;
/** Seconds the tick waits so the fill is under it before it draws. */
delay: number;
};
// Quality rule: the fill spring stays at or above a 0.8 damping ratio.
// An overshooting box would push its own tick past the frame, and the
// mark is the whole message. Variants differ in tempo, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost immediate, for a list of twenty rows.
subtle: {
fill: { type: "spring", stiffness: 820, damping: 52 },
stroke: 0.09,
delay: 0.02,
},
// The fill lands, then the mark arrives. All-purpose.
default: {
fill: { type: "spring", stiffness: 560, damping: 42 },
stroke: 0.18,
delay: 0.05,
},
// A visible beat between the fill and the mark, for a single consent
// row that deserves to be noticed.
playful: {
fill: { type: "spring", stiffness: 340, damping: 31 },
stroke: 0.28,
delay: 0.1,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a border or fill that is correctly
* toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const BOX = 20;
export default function CheckboxCheckDraw({
variant = "default",
label = "Email me the receipt",
hint,
defaultChecked = false,
accent = "#5B5BD6",
disabled = false,
onCheckedChange,
}: CheckboxCheckDrawProps) {
const [checked, setChecked] = useState(defaultChecked);
const [ring, setRing] = useState(false);
const labelId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const toggle = () => {
if (disabled) return;
const next = !checked;
setChecked(next);
onCheckedChange?.(next);
};
return (
<div
style={{
display: "flex",
alignItems: hint ? "flex-start" : "center",
gap: 11,
color: "inherit",
opacity: disabled ? 0.45 : 1,
}}
>
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-labelledby={labelId}
disabled={disabled}
onClick={toggle}
// The ring is for keyboard users only. `:focus-visible` is the
// browser's own answer to "was this focus deliberate?" — read it
// instead of guessing at the input modality.
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
style={{
position: "relative",
flexShrink: 0,
width: BOX,
height: BOX,
marginTop: hint ? 1 : 0,
padding: 0,
borderRadius: 6,
border: `1.5px solid ${checked ? accent : tone(28)}`,
background: "transparent",
color: "inherit",
cursor: disabled ? "not-allowed" : "pointer",
outline: "none",
boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
transition: "box-shadow 140ms ease-out, border-color 160ms ease-out",
WebkitTapHighlightColor: "transparent",
}}
>
{/* The fill is a square that grows out of the middle of the box.
Scaling a solid block costs one compositor property and never
deforms anything, because nothing inside it is text. */}
<motion.span
aria-hidden
initial={false}
animate={
reduceMotion
? { opacity: checked ? 1 : 0, scale: 1 }
: { scale: checked ? 1 : 0, opacity: checked ? 1 : 0 }
}
transition={
reduceMotion
? { duration: 0.1 }
: { ...cfg.fill, opacity: { duration: 0.1 } }
}
style={{
position: "absolute",
inset: -0.5,
borderRadius: 6,
background: accent,
}}
/>
<svg
viewBox="0 0 20 20"
width={BOX}
height={BOX}
fill="none"
aria-hidden
style={{ position: "absolute", inset: 0, display: "block" }}
>
{/* One open path, drawn end to end. `pathLength` normalizes the
geometry to 0..1 so the dash maths never has to know how
long the real path is. */}
<motion.path
d="M5.2 10.4 L8.6 13.8 L14.8 6.6"
stroke="#FFFFFF"
strokeWidth={2.1}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
// Reduced motion: the mark still appears, it just is not
// drawn — the state has to survive the setting.
animate={
reduceMotion
? { pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }
: { pathLength: checked ? 1 : 0, opacity: 1 }
}
transition={
reduceMotion
? { duration: 0 }
: {
duration: checked ? cfg.stroke : cfg.stroke * 0.6,
delay: checked ? cfg.delay : 0,
ease: checked ? "easeOut" : "easeIn",
}
}
/>
</svg>
</button>
<span
id={labelId}
onClick={toggle}
style={{
fontSize: 13.5,
lineHeight: 1.4,
cursor: disabled ? "not-allowed" : "pointer",
userSelect: "none",
}}
>
{label}
{hint ? (
<span style={{ display: "block", fontSize: 12, opacity: 0.55, marginTop: 2 }}>
{hint}
</span>
) : null}
</span>
</div>
);
}About this pattern
Two beats read as one gesture: an accent square grows out of the middle of the empty box, and a fraction of a second later the tick strokes across it. Ordering them that way is the whole trick — a mark that arrives before its background looks pasted on, and a mark that arrives with it is just a swap. The fill spring is damped so the box never overshoots and pushes the tick past its own frame. Unchecking runs shorter and eases in, because undoing a choice is not an event.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Option rows where the box has to stay quiet next to the total.
Related patterns
- Multi Select ChipsPicked options become chips in the row above the list, and the chips already there slide over to take each new one in.
- Toggle Group SelectOne highlight slides between grouped buttons and resizes to each label.
- Toggle Switch SlideThe knob travels across the track while the accent fill comes up under it, settling once.