Checkbox group rendered as a carpenter's tally: each check carves a notch stroke into its row and adds a stroke to an aggregate tally cluster in the header, where every fifth stroke slashes diagonally across its group of four.
npx shadcn add https://design.helpmarq.com /r/checkbox-tally-notch.jsonregistry/core/checkbox-tally-notch/component.tsx"use client";
import { useMemo, useRef, useState } from "react";
// Checkbox group rendered as a carpenter's tally: each check carves a notch
// stroke into the row and adds a stroke to an aggregate tally cluster, where
// every fifth stroke slashes diagonally across its group of four. Strokes
// draw on with a dashoffset sweep and retract on uncheck; per-stroke jitter
// is seeded by index so the carving looks hand-cut but renders identically
// every mount. Pure DOM+SVG, tokens only, reduced-motion snaps instantly.
export interface TallyNotchItem {
id: string;
label: string;
hint?: string;
}
export interface TallyNotchProps {
items: TallyNotchItem[];
defaultChecked?: string[];
onChange?: (checkedIds: string[]) => void;
/** heading shown above the group; also the group's accessible name */
label?: string;
className?: string;
}
// deterministic per-index jitter — mulberry32, so screenshots are stable
function jitter(i: number) {
let t = (i + 1) * 0x6d2b79f5;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
const r = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
return r * 2 - 1; // -1..1
}
const STROKE_STYLE = (drawn: boolean, reduced: boolean) =>
({
strokeDasharray: 1,
strokeDashoffset: drawn ? 0 : 1,
transition: reduced
? "none"
: `stroke-dashoffset 260ms ${drawn ? "cubic-bezier(0.22, 1, 0.36, 1)" : "cubic-bezier(0.55, 0, 0.55, 0.2)"}`,
}) as const;
function TallyCluster({
count,
max,
reduced,
}: {
count: number;
max: number;
reduced: boolean;
}) {
const groups = Math.ceil(max / 5);
const GW = 30; // group width
const H = 26;
const paths = useMemo(() => {
const out: { d: string; idx: number }[] = [];
for (let i = 0; i < max; i++) {
const g = Math.floor(i / 5);
const k = i % 5;
const gx = g * (GW + 10);
if (k < 4) {
// vertical stroke with seeded lean and length variance
const x = gx + 4 + k * 6 + jitter(i) * 1.2;
const lean = jitter(i * 7 + 3) * 1.8;
const top = 3 + jitter(i * 13 + 5) * 1.4;
out.push({ d: `M ${x + lean} ${top} L ${x - lean} ${H - 3}`, idx: i });
} else {
// the fifth: a diagonal slash across the group of four
const y1 = 6 + jitter(i) * 1.5;
const y2 = H - 6 + jitter(i * 3) * 1.5;
out.push({ d: `M ${gx - 1} ${y1} L ${gx + 23} ${y2}`, idx: i });
}
}
return out;
}, [max]);
return (
<svg
aria-hidden
width={groups * (GW + 10) - 10}
height={H}
viewBox={`0 0 ${groups * (GW + 10) - 10} ${H}`}
className="overflow-visible"
>
{paths.map((p) => (
<path
key={p.idx}
d={p.d}
pathLength={1}
fill="none"
stroke="var(--foreground)"
strokeWidth={p.idx % 5 === 4 ? 2 : 1.75}
strokeLinecap="round"
style={{
...STROKE_STYLE(p.idx < count, reduced),
// later strokes draw a beat after earlier ones when several
// arrive at once (e.g. defaultChecked mount)
transitionDelay: reduced ? "0ms" : `${(p.idx % 5) * 30}ms`,
}}
/>
))}
</svg>
);
}
function NotchMark({ checked, reduced }: { checked: boolean; reduced: boolean }) {
return (
<svg aria-hidden width={20} height={20} viewBox="0 0 20 20" className="shrink-0">
<rect
x={1.5}
y={1.5}
width={17}
height={17}
rx={4}
fill="none"
stroke={checked ? "var(--foreground)" : "var(--border)"}
strokeWidth={1.5}
style={{ transition: reduced ? "none" : "stroke 200ms ease" }}
/>
{/* two-cut carved notch: down-stroke lands, then the long up-stroke */}
<path
d="M 5.5 10.5 L 8.5 13.8"
pathLength={1}
fill="none"
stroke="var(--foreground)"
strokeWidth={2}
strokeLinecap="round"
style={STROKE_STYLE(checked, reduced)}
/>
<path
d="M 8.5 13.8 L 14.5 5.8"
pathLength={1}
fill="none"
stroke="var(--foreground)"
strokeWidth={2}
strokeLinecap="round"
style={{
...STROKE_STYLE(checked, reduced),
transitionDelay: reduced ? "0ms" : checked ? "120ms" : "0ms",
}}
/>
</svg>
);
}
export function TallyNotch({
items,
defaultChecked = [],
onChange,
label = "Tally",
className = "",
}: TallyNotchProps) {
const [checked, setChecked] = useState<Set<string>>(() => new Set(defaultChecked));
const reduced = useRef(
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
).current;
const toggle = (id: string) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange?.(items.filter((it) => next.has(it.id)).map((it) => it.id));
return next;
});
};
return (
<div
role="group"
aria-label={label}
className={["w-full rounded-md border border-border bg-surface", className].join(" ")}
>
<header className="flex items-center justify-between gap-4 border-b border-border px-4 py-3">
<span className="font-mono text-xs tracking-widest text-muted uppercase">{label}</span>
<div className="flex items-center gap-3">
<TallyCluster count={checked.size} max={items.length} reduced={reduced} />
<span
aria-live="polite"
className="min-w-[3ch] text-right font-mono text-xs tabular-nums text-muted"
>
{checked.size}/{items.length}
</span>
</div>
</header>
<div className="flex flex-col">
{items.map((it) => {
const isChecked = checked.has(it.id);
return (
<button
key={it.id}
type="button"
role="checkbox"
aria-checked={isChecked}
onClick={() => toggle(it.id)}
className={[
"group flex cursor-pointer items-center gap-3 border-b border-border px-4 py-3 text-left last:border-b-0",
"transition-colors duration-150 hover:bg-border/40",
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent",
].join(" ")}
>
<NotchMark checked={isChecked} reduced={reduced} />
<span className="flex min-w-0 flex-1 flex-col">
<span
className={[
"truncate text-sm transition-colors duration-200",
isChecked ? "text-muted" : "text-foreground",
].join(" ")}
>
{it.label}
</span>
{it.hint ? (
<span className="truncate font-mono text-[11px] text-muted">{it.hint}</span>
) : null}
</span>
</button>
);
})}
</div>
</div>
);
}
Checkbox group rendered as a carpenter's tally: each check carves a notch stroke into its row and adds a stroke to an aggregate tally cluster in the header, where every fifth stroke slashes diagonally across its group of four. Strokes draw on with a dashoffset sweep and retract on uncheck.
Build a checkbox group styled as a carved tally board. STRUCTURE: a bordered bg-surface card (role=group with aria-label from the label prop) whose header carries the group label, an SVG tally cluster, and an aria-live 'checked/total' mono counter; below, one full-width row per item, each a real <button role=checkbox aria-checked> containing a 20px SVG notch mark, the label, and an optional mono hint line. STROKES: every stroke is an SVG <path pathLength={1}> with strokeDasharray 1, drawn by transitioning strokeDashoffset 1 -> 0 over 260ms (ease-out-back-ish cubic-bezier(0.22,1,0.36,1)) and retracted on uncheck with an ease-in curve; the row's notch is two cuts, a short down-stroke then the long up-stroke delayed 120ms so the carve reads as two motions. TALLY CLUSTER: render items.length strokes grouped in fives, four leaning verticals (6px pitch) then a diagonal slash across the group; stroke k is drawn when checkedCount > k, with a 30ms per-position stagger so a batch (e.g. defaultChecked at mount) carves in sequence. Jitter every stroke's lean, length, and endpoints with a deterministic mulberry32-style hash of its index so the carving looks hand-cut but renders byte-identical every mount (stable screenshots). INK: strokes stroke='var(--foreground)', unchecked notch boxes stroke='var(--border)', all chrome from the border/surface/muted tokens — zero hex in markup, both themes render. STATE: uncontrolled Set<string> seeded from defaultChecked, onChange fires with checked ids in items order; checked rows dim their label to text-muted. INTERACTION: rows are native buttons so Space/Enter toggle and Tab reaches every row; hover tints the row bg-border/40; focus-visible draws an inset 2px accent outline. Reduced motion: strokes and notches snap with transition:none. No canvas, no timers, no observers — the only state is the Set, everything else is CSS transitions on SVG attributes.