"use client";
import * as React from "react";
import { motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export type CheckboxSize = "sm" | "md" | "lg";
const SIZES: Record<CheckboxSize, { box: string; text: string; gap: string; desc: string }> = {
sm: { box: "size-4 rounded-[5px]", text: "text-[13px]", gap: "gap-2.5", desc: "text-[12px]" },
md: { box: "size-5 rounded-md", text: "text-[14px]", gap: "gap-3", desc: "text-[13px]" },
lg: { box: "size-6 rounded-[7px]", text: "text-[15px]", gap: "gap-3.5", desc: "text-[13px]" },
};
// Both paths share one command structure (M, L, L), so Motion can morph between them.
const CHECK_PATH = "M4.2 8.4 L6.9 11 L11.8 5.2";
const DASH_PATH = "M4.5 8 L8 8 L11.5 8";
export interface AnimatedCheckboxProps
extends Omit<React.ComponentPropsWithoutRef<"input">, "type" | "size" | "children" | "checked" | "defaultChecked"> {
/** Controlled checked state. */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
/** Mixed state. Written to the input's DOM `indeterminate` property (it has no HTML attribute). */
indeterminate?: boolean;
/** Called with the next checked state. The native `onChange` still fires too. */
onCheckedChange?: (checked: boolean) => void;
/** Visible label; clicking it toggles the box. */
label?: React.ReactNode;
/** Secondary text, linked to the input with aria-describedby. */
description?: React.ReactNode;
size?: CheckboxSize;
/** Fill colour of the checked box (any CSS colour). */
accent?: string;
/** Duration of the check stroke draw, in ms. */
duration?: number;
/** Scale of the box while pressed (1 disables the press spring). */
pressScale?: number;
/** Class names for the outer label element. */
className?: string;
}
/**
* A checkbox built on a real, visually hidden <input type="checkbox"> laid over a styled box,
* so clicks, Space, form submission, validation and assistive tech all behave natively.
* The check is drawn with an animated stroke (pathLength → stroke-dashoffset) and morphs into
* a dash for the indeterminate state; pressing the box gives it a small spring.
*/
export function AnimatedCheckbox({
checked,
defaultChecked = false,
indeterminate = false,
onCheckedChange,
onChange,
label,
description,
size = "md",
accent = "#FF7448",
duration = 280,
pressScale = 0.85,
disabled,
id: idProp,
className = "",
style,
...props
}: AnimatedCheckboxProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const id = idProp ?? `cbx-${uid}`;
const descId = `${id}-desc`;
const inputRef = React.useRef<HTMLInputElement>(null);
const [inner, setInner] = React.useState(defaultChecked);
const isChecked = checked ?? inner;
const on = isChecked || indeterminate;
const s = SIZES[size];
// `indeterminate` exists only as a DOM property, so it is synced after every render
// (a native click clears it, and the prop must win again).
React.useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
});
const seconds = reduceMotion ? 0 : duration / 1000;
const spring = { type: "spring" as const, stiffness: 600, damping: 22, mass: 0.6 };
return (
<motion.label
htmlFor={id}
initial={false}
whileTap={disabled || reduceMotion ? undefined : "pressed"}
className={`group/cbx inline-flex cursor-pointer items-start ${s.gap} has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 ${className}`}
style={{ ["--cbx-accent" as string]: accent, ...style }}
>
<span className="relative inline-grid shrink-0 place-items-center pt-px">
<input
{...props}
ref={inputRef}
id={id}
type="checkbox"
checked={isChecked}
disabled={disabled}
aria-describedby={description ? [descId, props["aria-describedby"]].filter(Boolean).join(" ") : props["aria-describedby"]}
onChange={(event) => {
if (checked === undefined) setInner(event.target.checked);
onCheckedChange?.(event.target.checked);
onChange?.(event);
}}
className="peer absolute inset-0 z-10 m-0 size-full cursor-[inherit] appearance-none opacity-0"
/>
<motion.span
aria-hidden="true"
variants={{ pressed: { scale: pressScale } }}
animate={{ scale: 1 }}
transition={spring}
className={`relative grid ${s.box} place-items-center border transition-[border-color,background-color,box-shadow] duration-150 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-solid peer-focus-visible:outline-[var(--cbx-accent)] motion-reduce:transition-none ${
on
? "border-[var(--cbx-accent)] bg-[var(--cbx-accent)]"
: "border-zinc-900/25 bg-white group-hover/cbx:border-zinc-900/45 dark:border-white/25 dark:bg-zinc-950 dark:group-hover/cbx:border-white/45"
}`}
>
{/* A soft ring that blooms outward each time the box turns on. */}
<motion.span
className="pointer-events-none absolute inset-0 rounded-[inherit] border border-[var(--cbx-accent)]"
initial={false}
animate={on && !reduceMotion ? { scale: [1, 1.7], opacity: [0.55, 0] } : { scale: 1, opacity: 0 }}
transition={{ duration: 0.45, ease: "easeOut" }}
/>
<svg viewBox="0 0 16 16" className="size-full text-white dark:text-zinc-950" fill="none">
<motion.path
initial={false}
animate={{ d: indeterminate ? DASH_PATH : CHECK_PATH, pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
transition={{
pathLength: { duration: on ? seconds : seconds * 0.5, ease: [0.65, 0, 0.35, 1], delay: on && !reduceMotion ? 0.04 : 0 },
d: reduceMotion ? { duration: 0 } : spring,
opacity: { duration: on ? 0.01 : seconds * 0.5 },
}}
stroke="currentColor"
strokeWidth={size === "sm" ? 2.2 : 2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</span>
{(label || description) && (
<span className="flex min-w-0 flex-col gap-0.5">
{label && <span className={`${s.text} font-medium leading-tight text-zinc-900 dark:text-zinc-100`}>{label}</span>}
{description && (
<span id={descId} className={`${s.desc} leading-snug text-zinc-500 dark:text-zinc-400`}>
{description}
</span>
)}
</span>
)}
</motion.label>
);
}
/* -------------------------------------------------------------------------- */
/* CheckboxGroup */
/* -------------------------------------------------------------------------- */
export interface CheckboxGroupOption {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
}
export interface CheckboxGroupProps
extends Omit<React.ComponentPropsWithoutRef<"fieldset">, "onChange" | "defaultValue" | "children"> {
/** Group legend (rendered as a <legend>). */
legend: React.ReactNode;
options: CheckboxGroupOption[];
/** Controlled list of checked values. */
value?: string[];
/** Initial checked values when uncontrolled. */
defaultValue?: string[];
onChange?: (values: string[]) => void;
/** Label of the tri-state parent. Pass `false` to hide it. */
selectAll?: React.ReactNode | false;
/** Name submitted for each checked value in native forms. */
name?: string;
size?: CheckboxSize;
accent?: string;
duration?: number;
pressScale?: number;
}
/**
* A fieldset of AnimatedCheckboxes with an optional tri-state "select all" parent: it is checked
* when every enabled option is, indeterminate when some are, and toggles all enabled options.
* Disabled options keep their state.
*/
export function CheckboxGroup({
legend,
options,
value,
defaultValue,
onChange,
selectAll = "Select all",
name,
size = "md",
accent = "#FF7448",
duration = 280,
pressScale = 0.85,
disabled,
className = "",
...props
}: CheckboxGroupProps) {
const uid = React.useId();
const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);
const values = value ?? inner;
const set = (next: string[]) => {
if (value === undefined) setInner(next);
onChange?.(next);
};
const enabled = options.filter((o) => !o.disabled);
const checkedEnabled = enabled.filter((o) => values.includes(o.value)).length;
const all = enabled.length > 0 && checkedEnabled === enabled.length;
const some = checkedEnabled > 0 && !all;
const childId = (i: number) => `cbg-${uid}-${i}`;
const shared = { size, accent, duration, pressScale };
const toggleAll = () => {
const locked = values.filter((v) => options.some((o) => o.disabled && o.value === v));
set(all ? locked : [...locked, ...enabled.map((o) => o.value)]);
};
return (
<fieldset disabled={disabled} className={`m-0 min-w-0 border-0 p-0 ${className}`} {...props}>
<legend className="mb-3 p-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{legend}</legend>
{selectAll !== false && (
<div className="mb-2 border-b border-zinc-900/10 pb-3 dark:border-white/10">
<AnimatedCheckbox
{...shared}
label={selectAll}
checked={all}
indeterminate={some}
aria-controls={options.map((_, i) => childId(i)).join(" ")}
description={
<span className="font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums">
{checkedEnabled + values.filter((v) => options.some((o) => o.disabled && o.value === v)).length} of {options.length} on
</span>
}
onCheckedChange={toggleAll}
/>
</div>
)}
<div className={`flex flex-col gap-3 ${selectAll !== false ? (size === "sm" ? "pl-[1.625rem]" : size === "lg" ? "pl-[2.375rem]" : "pl-8") : ""}`}>
{options.map((o, i) => (
<AnimatedCheckbox
key={o.value}
{...shared}
id={childId(i)}
name={name}
value={o.value}
label={o.label}
description={o.description}
disabled={o.disabled}
checked={values.includes(o.value)}
onCheckedChange={(on) => set(on ? [...values, o.value] : values.filter((v) => v !== o.value))}
/>
))}
</div>
</fieldset>
);
}