Skip to main content

ns-ui

Checkbox Tally Notch

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.

Usage

registry/core/checkbox-tally-notch/demo.tsx
"use client";

import { TallyNotch } from "./component";

const ITEMS = [
  { id: "dns", label: "Point DNS at the new origin", hint: "cutover" },
  { id: "cert", label: "Issue TLS certificate", hint: "cutover" },
  { id: "env", label: "Set env vars in every deploy target", hint: "config" },
  { id: "smoke", label: "Smoke-test the live URL", hint: "verify" },
  { id: "alerts", label: "Wire uptime alerts", hint: "verify" },
  { id: "rollback", label: "Document the rollback path", hint: "safety" },
  { id: "announce", label: "Announce the cutover window", hint: "comms" },
];

export default function TallyNotchDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / checkbox-tally-notch
        </p>
        <TallyNotch label="Launch checklist" items={ITEMS} defaultChecked={["dns", "cert", "env"]} />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          each check carves a stroke into the tally — the fifth slashes across
          its group of four
        </p>
      </div>
    </main>
  );
}

Component

registry/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 {
  /** the checkbox rows, in order */
  items: TallyNotchItem[];
  /** ids checked at mount */
  defaultChecked?: string[];
  /** called with the full list of checked ids after any toggle */
  onChange?: (checkedIds: string[]) => void;
  /** heading shown above the group; also the group's accessible name */
  label?: string;
  /** extra classes merged onto the rendered root element */
  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
}

// delay folded into the shorthand itself, not a separate transitionDelay
// longhand — mixing transition (shorthand) and transitionDelay (longhand)
// for the same property gives React two owners for one CSS value and it
// warns (and can genuinely race) on rerender.
const STROKE_STYLE = (drawn: boolean, reduced: boolean, delayMs = 0) =>
  ({
    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)"} ${delayMs}ms`,
  }) 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"
          // later strokes draw a beat after earlier ones when several
          // arrive at once (e.g. defaultChecked mount)
          style={STROKE_STYLE(p.idx < count, reduced, reduced ? 0 : (p.idx % 5) * 30)}
        />
      ))}
    </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, reduced ? 0 : checked ? 120 : 0)}
      />
    </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-ns-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-ns-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-ns-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-ns-muted" : "text-foreground",
                  ].join(" ")}
                >
                  {it.label}
                </span>
                {it.hint ? (
                  <span className="truncate font-mono text-[11px] text-ns-muted">{it.hint}</span>
                ) : null}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/checkbox-tally-notch.tsx, so point the import there.

Use when a checklist where the running total matters as much as the individual rows, each check carving a stroke into a header tally cluster (every fifth slashing across the previous four) so progress is countable at a glance without reading the list; pick checkbox-ink-stroke instead for a standalone checkbox or a tri-state parent with no aggregate to show, or checkbox-domino-run when the list is long enough that interruptible select-all is the real problem.

Install

npx shadcn add https://design.helpmarq.com/r/checkbox-tally-notch.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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-ns-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.

Props

PropTypeDefaultDescription
itemsTallyNotchItem[]—the checkbox rows, in order
defaultChecked?string[][]ids checked at mount
onChange?(checkedIds: string[]) => void—called with the full list of checked ids after any toggle
label?string"Tally"heading shown above the group; also the group's accessible name
className?string—extra classes merged onto the rendered root element