Animated Checkbox
A real checkbox with a check that draws itself in: the fill opens from the centre, the box pops, a ring and six sparks leave it, and a checked label can strike through. Indeterminate state, three sizes, controlled or not. No dependencies.
DOM + CSS0 deps1 file · 7 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/-]+\))$/i;
const SPARKS = [0, 60, 120, 180, 240, 300];
const CSS = [
".acb{position:relative;-webkit-tap-highlight-color:transparent;--acb-s:var(--acb-size,24px);--acb-spring:cubic-bezier(.34,1.56,.64,1);--acb-out:cubic-bezier(.2,.8,.2,1)}",
".acb[data-size=sm]{--acb-s:var(--acb-size,18px)}",
".acb[data-size=lg]{--acb-s:var(--acb-size,32px)}",
".acb[data-disabled]{cursor:not-allowed;opacity:.45}",
"@media (pointer:coarse){.acb{min-height:40px}}",
".acb-in{position:absolute;left:0;top:50%;width:var(--acb-s);height:var(--acb-s);margin:0;transform:translateY(-50%);opacity:0;cursor:inherit}",
".acb-box{position:relative;flex:none;width:var(--acb-s);height:var(--acb-s);box-sizing:border-box;border:2px solid color-mix(in srgb,currentColor 36%,transparent);border-radius:calc(var(--acb-s)*.3);transition:border-color .2s ease,background-color .2s ease,transform .45s var(--acb-spring),box-shadow .3s ease}",
".acb:not([data-disabled]):hover .acb-in:not(:checked):not(:indeterminate)~.acb-box{border-color:color-mix(in srgb,currentColor 66%,transparent);background-color:color-mix(in srgb,currentColor 6%,transparent)}",
".acb:not([data-disabled]):active .acb-box{transform:scale(.86);transition-duration:.12s,.12s,.12s,.3s}",
".acb-in:checked~.acb-box,.acb-in:indeterminate~.acb-box{border-color:var(--acb-c);box-shadow:0 5px 16px -6px var(--acb-c)}",
".acb-in:focus-visible~.acb-box{outline:2px solid var(--acb-c);outline-offset:3px}",
".acb-bg{position:absolute;inset:-2px;border-radius:inherit;background:var(--acb-c);clip-path:circle(0% at 50% 50%);transition:clip-path .16s ease-in}",
".acb-in:checked~.acb-box .acb-bg,.acb-in:indeterminate~.acb-box .acb-bg{clip-path:circle(75% at 50% 50%);transition:clip-path .38s var(--acb-out)}",
".acb-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}",
".acb-svg path{fill:none;stroke:var(--acb-k);stroke-width:2.8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .14s ease-in}",
".acb-in:checked:not(:indeterminate)~.acb-box .acb-tick,.acb-in:indeterminate~.acb-box .acb-dash{stroke-dashoffset:0;transition:stroke-dashoffset .3s cubic-bezier(.65,0,.35,1) .1s}",
".acb-ring{position:absolute;inset:-2px;border-radius:inherit;border:2px solid var(--acb-c);opacity:0;pointer-events:none}",
".acb-spark{position:absolute;left:50%;top:50%;width:3px;height:3px;margin:-1.5px 0 0 -1.5px;border-radius:50%;background:var(--acb-c);opacity:0;pointer-events:none}",
"@keyframes acb-pop{0%{transform:scale(.86)}45%{transform:scale(1.14)}100%{transform:scale(1)}}",
"@keyframes acb-ring{0%{opacity:.6;transform:scale(1)}100%{opacity:0;transform:scale(1.9)}}",
"@keyframes acb-spark{0%{opacity:1;transform:rotate(var(--a)) translateY(calc(var(--acb-s)*-.5)) scale(1)}100%{opacity:0;transform:rotate(var(--a)) translateY(calc(var(--acb-s)*-1)) scale(.2)}}",
"[data-ready] .acb-in:checked:not(:indeterminate)~.acb-box{animation:acb-pop .5s var(--acb-out)}",
"[data-ready] .acb-in:checked:not(:indeterminate)~.acb-box .acb-ring{animation:acb-ring .6s var(--acb-out) .05s}",
"[data-ready] .acb-in:checked:not(:indeterminate)~.acb-box .acb-spark{animation:acb-spark .55s var(--acb-out) .08s}",
".acb-label{font-size:var(--acb-fs,.875rem);font-weight:500;line-height:1.35;transition:opacity .3s ease .08s}",
".acb-text{background-image:linear-gradient(currentColor,currentColor);background-repeat:no-repeat;background-position:0 58%;background-size:0% 2px;-webkit-box-decoration-break:clone;box-decoration-break:clone;transition:background-size .12s ease-in}",
".acb[data-strike] .acb-in:checked~.acb-label{opacity:.48}",
".acb[data-strike] .acb-in:checked~.acb-label .acb-text{background-size:100% 2px;transition:background-size .36s cubic-bezier(.65,0,.35,1) .1s}",
"@media (prefers-reduced-motion:reduce){",
".acb-box,.acb-bg,.acb-svg path,.acb-label,.acb-text{transition-duration:.01s!important;transition-delay:0s!important}",
"[data-ready] .acb-in:checked~.acb-box,[data-ready] .acb-ring,[data-ready] .acb-spark{animation:none!important}",
".acb:not([data-disabled]):active .acb-box{transform:none}",
"}",
].join("\n");
export function AnimatedCheckbox({
label = "Send me the changelog",
defaultChecked = false,
checked,
onCheckedChange,
indeterminate = false,
disabled = false,
size = "md",
color = "#ef4444",
strike = false,
name,
value,
className = "",
}: {
label?: ReactNode;
defaultChecked?: boolean;
/** Controlled value. Leave undefined to let the checkbox own its state. */
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
/** Shows the dash (mixed) state, e.g. for a select-all parent. */
indeterminate?: boolean;
disabled?: boolean;
size?: "sm" | "md" | "lg";
/** Fill colour of the checked box (hex, rgb() or hsl()). */
color?: string;
/** Draw a line through the label and dim it while checked. */
strike?: boolean;
name?: string;
value?: string;
className?: string;
}) {
const [inner, setInner] = useState(defaultChecked);
const controlled = checked !== undefined;
const isChecked = controlled ? checked : inner;
const rootRef = useRef<HTMLLabelElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const accent = SAFE_COLOR.test(color.trim()) ? color.trim() : "#ef4444";
// Native indeterminate is a DOM property, not an attribute.
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate, isChecked]);
// Burst animations only play after mount, so a pre-checked box does not fire on load.
useEffect(() => {
const id = requestAnimationFrame(() => rootRef.current?.toggleAttribute("data-ready", true));
return () => cancelAnimationFrame(id);
}, []);
return (
<>
<style>
{CSS}
</style>
<label
ref={rootRef}
className={"acb flex cursor-pointer select-none items-center gap-3 " + className}
data-size={size}
data-strike={strike ? "" : undefined}
data-disabled={disabled ? "" : undefined}
style={{ "--acb-c": accent, "--acb-k": "#fff" } as CSSProperties}
>
<input
ref={inputRef}
type="checkbox"
className="acb-in"
checked={isChecked}
disabled={disabled}
name={name}
value={value}
onChange={(e) => {
if (!controlled) setInner(e.target.checked);
onCheckedChange?.(e.target.checked);
}}
/>
<span className="acb-box" aria-hidden="true">
<span className="acb-bg" />
<svg className="acb-svg" viewBox="0 0 24 24">
<path className="acb-tick" d="M6.2 12.8l3.8 3.8 7.8-9" pathLength={1} />
<path className="acb-dash" d="M7 12h10" pathLength={1} />
</svg>
<span className="acb-ring" />
{SPARKS.map((a) => (
<span key={a} className="acb-spark" style={{ "--a": a + 20 + "deg" } as CSSProperties} />
))}
</span>
<span className="acb-label">
<span className="acb-text">{label}</span>
</span>
</label>
</>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
Under the drawing there is a native checkbox, so forms, labels, autofill, disabled and the indeterminate state all behave without extra wiring. The visible box is its sibling and reads the input's :checked and :indeterminate state in CSS. Checking runs one sequence: the box squashes and pops (a spring-eased 0.5s keyframe), the accent fill opens from the centre as a growing circular clip, the tick draws along its path with a stroke-dashoffset transition (0.3s, after a 0.1s lead-in), a ring expands off the box and six sparks fly out from its edge. Unchecking runs the same steps in 0.14s, fast and quiet. Pressing squashes the box to 0.86 and releases on a springy overshoot. Set strike and the label gains a line that draws through it while the text dims, which turns a list of these into a checklist. The dash for a mixed select-all parent is drawn the same way. Leave checked undefined and it owns its state from defaultChecked; pass checked and onCheckedChange and you own it. Three sizes, an accent colour prop, and a --acb-size custom property for sizes in between. The label, border and focus ring follow the current text colour, so it reads on light and dark pages alike; the fill is the accent. Pre-checked boxes do not fire the burst on load.
Curator’s note
Animated Checkbox, rebuilt in October. The first version swapped a framer-motion tick onto a styled span; this one is a real input underneath and needs nothing but React.