Skip to main content

ns-ui

Bitting Cut

Every passkey in a sign-in-methods list gets a physical key blade whose six notch depths are cut deterministically from its credential id, so the MacBook passkey and the phone passkey stay visually distinct objects across sessions.

Use when a Settings > Sign-in methods list where each passkey needs a recognizable, non-random visual fingerprint derived from its own credential id — enroll plays a one-shot key-cutting pass, remove arms then files the profile flat before the row leaves. Pick pin-tumbler instead when the point is animating the ACT of unlocking rather than fingerprinting a stored credential, and card-number-emboss when the physical-object metaphor is a payment card, not a key.

Install

npx shadcn add https://design.helpmarq.com/r/bitting-cut.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.

Source
registry/core/bitting-cut/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// BittingCut — a passkey's credential ID rendered as a physical key blade, so
// "the MacBook passkey" and "the phone passkey" are visually distinct objects
// a person recognizes across sessions, not two identical shield icons.
//
// The bitting profile (6 notch depths, quantized to 5 levels — real key-cut
// vocabulary) is a PURE function of the credential id: same id -> same cuts,
// every mount, forever. That is the whole point — recognition requires
// determinism, so there is no randomness anywhere in the depth derivation.
// Only a freshly-enrolled key (added at runtime, given a fresh random
// credential id) gets a NEW, still-deterministic-for-that-id profile.
//
// Enrollment plays a one-shot 700ms cutter pass: a cutting wheel sweeps the
// blade once and each notch STEPS to its final depth exactly as the wheel
// crosses it (a cutting wheel does not tween — it commits), with a brief
// --foreground burr flash at the instant of each cut. Removal runs the exact
// same traversal in "file" mode — every notch steps back to blank as the
// wheel passes — before the row collapses out of the list. Reduced motion
// skips both passes and lands directly on the finished (cut or blank) shape.
//
// Real list, real buttons, real text: name / created / last-used are plain
// DOM text beside the art, the SVG key is aria-hidden, and removal goes
// through an arm-then-confirm button (mirrors the repo's existing evict
// pattern) with a polite live-region announcement once it lands.
// ---------------------------------------------------------------------------

export interface Passkey {
  /** stable credential id — the ONLY input the bitting profile is derived from */
  id: string;
  /** device or credential label, e.g. "iPhone 16" */
  name: string;
  /** device glyph shown in the key's bow; inferred from `name` when omitted */
  device?: "phone" | "laptop" | "tablet" | "desktop" | "key" | "generic";
  /** epoch ms (or Date) this credential was enrolled */
  createdAt: number | Date;
  /** epoch ms (or Date) of last use; null/undefined = never used since enrollment */
  lastUsedAt?: number | Date | null;
}

export interface BittingCutProps {
  /** the starting set of enrolled passkeys, rendered already-cut (no entrance animation) */
  initialPasskeys?: Passkey[];
  /** stop offering "Add passkey" once the list reaches this size. default 6 */
  maxPasskeys?: number;
  /** accessible name for the list. default "Passkeys" */
  ariaLabel?: string;
  /** fired once a newly-generated passkey has been appended */
  onAdd?: (passkey: Passkey) => void;
  /** fired once a passkey has finished its removal (file + collapse) pass */
  onRemove?: (id: string) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

// ---- deterministic bitting -------------------------------------------------

const N_NOTCHES = 6;
const N_LEVELS = 5; // depths 0..4
const CUT_MS = 700;
const ZERO_DEPTHS = new Array(N_NOTCHES).fill(0) as number[];
const CENTER_FRACTIONS = Array.from({ length: N_NOTCHES }, (_, i) => (i + 0.5) / N_NOTCHES);

function fnv1a(input: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

/** pure function of `credentialId` — identical every call, every mount. */
function bittingDepths(credentialId: string): number[] {
  const depths: number[] = [];
  for (let i = 0; i < N_NOTCHES; i++) {
    depths.push(fnv1a(`${credentialId}#${i}`) % N_LEVELS);
  }
  return depths;
}

// ---- key geometry (single blade path, ring bow) ----------------------------

const BOW_CX = 16;
const BOW_CY = 20;
const BOW_R = 11;
const X0 = 27; // blade shoulder, meets the bow ring
const X1 = 100; // blade tip
const TOP = 15;
const BASE = 28;
const DEPTH_STEP = 2.6;
const VIEW_W = 112;
const VIEW_H = 40;

function round2(v: number): number {
  return Math.round(v * 100) / 100;
}

/** the blade silhouette as ONE closed path — stepped cuts, never a smooth curve. */
function bladePath(depths: number[]): string {
  const n = depths.length;
  const cellW = (X1 - X0) / n;
  const xs = Array.from({ length: n + 1 }, (_, i) => X0 + i * cellW);
  const ys = depths.map((d) => BASE - d * DEPTH_STEP);
  let d = `M${round2(xs[0])},${TOP} L${round2(xs[n])},${TOP} L${round2(xs[n])},${round2(ys[n - 1])}`;
  for (let i = n - 1; i >= 0; i--) {
    d += ` L${round2(xs[i])},${round2(ys[i])}`;
    if (i > 0) d += ` L${round2(xs[i])},${round2(ys[i - 1])}`;
  }
  d += ` L${round2(xs[0])},${BASE} Z`;
  return d;
}

function notchCenters(): number[] {
  const cellW = (X1 - X0) / N_NOTCHES;
  return CENTER_FRACTIONS.map((_, i) => X0 + cellW * (i + 0.5));
}

// ---- device glyph (lives inside the bow ring) ------------------------------

type DeviceKind = NonNullable<Passkey["device"]>;

function inferDevice(name: string): DeviceKind {
  const n = name.toLowerCase();
  if (/ipad|tablet/.test(n)) return "tablet";
  if (/iphone|pixel|galaxy|android|phone/.test(n)) return "phone";
  if (/macbook|laptop|notebook|thinkpad|chromebook/.test(n)) return "laptop";
  if (/imac|mac mini|mac studio|desktop|\bpc\b|windows/.test(n)) return "desktop";
  if (/yubikey|titan|security key|\bkey\b/.test(n)) return "key";
  return "generic";
}

function DeviceGlyph({ kind }: { kind: DeviceKind }) {
  const stroke = { stroke: "var(--foreground)", strokeWidth: 1.1, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, fill: "none" };
  switch (kind) {
    case "phone":
      return <rect x={-2.8} y={-5} width={5.6} height={10} rx={1.3} {...stroke} />;
    case "tablet":
      return <rect x={-4.2} y={-3.2} width={8.4} height={6.4} rx={1} {...stroke} />;
    case "laptop":
      return (
        <>
          <rect x={-3.4} y={-4.2} width={6.8} height={4.6} rx={0.6} {...stroke} />
          <line x1={-4.6} y1={1.6} x2={4.6} y2={1.6} {...stroke} />
        </>
      );
    case "desktop":
      return (
        <>
          <rect x={-3.6} y={-4.2} width={7.2} height={5} rx={0.6} {...stroke} />
          <line x1={0} y1={0.8} x2={0} y2={2.6} {...stroke} />
          <line x1={-2.2} y1={2.6} x2={2.2} y2={2.6} {...stroke} />
        </>
      );
    case "key":
      return (
        <>
          <rect x={-2.2} y={-4.2} width={4.4} height={6} rx={0.8} {...stroke} />
          <line x1={-1.4} y1={1.8} x2={-1.4} y2={3.6} {...stroke} />
          <line x1={1.4} y1={1.8} x2={1.4} y2={3.6} {...stroke} />
        </>
      );
    default:
      return <rect x={-2.3} y={-2.3} width={4.6} height={4.6} transform="rotate(45)" {...stroke} />;
  }
}

// ---- time formatting (SSR-safe: "now" arrives after mount) ----------------

const DAY_MS = 86400000;

function formatCreated(ts: number | Date): string {
  const d = ts instanceof Date ? ts : new Date(ts);
  return new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric" }).format(d);
}

function formatLastUsed(lastUsedAt: number | Date | null | undefined, now: number | null): string {
  if (lastUsedAt == null) return "Never used";
  if (now == null) return "…";
  const ts = lastUsedAt instanceof Date ? lastUsedAt.getTime() : lastUsedAt;
  const diff = Math.max(0, now - ts);
  if (diff < DAY_MS) return "Last used today";
  const days = Math.floor(diff / DAY_MS);
  if (days === 1) return "Last used yesterday";
  if (days < 7) return `Last used ${days} days ago`;
  const weeks = Math.floor(days / 7);
  if (weeks < 5) return `Last used ${weeks} week${weeks === 1 ? "" : "s"} ago`;
  return `Last used ${formatCreated(ts)}`;
}

function remainWord(n: number): string {
  return n === 1 ? "1 remains" : `${n} remain`;
}

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

// ---- row ---------------------------------------------------------------

type CutPhase = "entering" | "settled" | "filing" | "exiting";

interface RowState extends Passkey {
  cutPhase: CutPhase;
}

const NOTCH_X = notchCenters();

// KeyArt composes the bow ring, the device glyph, and the blade in one
// document (single viewBox, single stacking order) and owns the row's cutter
// animation: a smooth 700ms sweep of the cutter graphic, with each notch's
// depth stepping (never tweening) to its target the instant the sweep
// crosses it.
function KeyArt({
  credentialId,
  device,
  cutPhase,
  reducedMotion,
  onCutSettled,
  onFileSettled,
}: {
  credentialId: string;
  device: DeviceKind;
  cutPhase: CutPhase;
  reducedMotion: boolean;
  onCutSettled: () => void;
  onFileSettled: () => void;
}) {
  const target = useRef(bittingDepths(credentialId)).current;
  const [depths, setDepths] = useState<number[]>(() => (cutPhase === "settled" ? target : ZERO_DEPTHS));
  const [cutterRun, setCutterRun] = useState(false);
  const flashRefs = useRef<(SVGRectElement | null)[]>([]);

  function flashNotch(i: number) {
    const el = flashRefs.current[i];
    if (!el) return;
    el.style.transition = "none";
    el.style.opacity = "1";
    requestAnimationFrame(() => {
      el.style.transition = "opacity 160ms ease-out";
      el.style.opacity = "0";
    });
  }

  useEffect(() => {
    if (cutPhase !== "entering" && cutPhase !== "filing") return;
    const goal = cutPhase === "entering" ? target : ZERO_DEPTHS;
    const settle = cutPhase === "entering" ? onCutSettled : onFileSettled;

    if (reducedMotion) {
      setDepths(goal);
      settle();
      return;
    }

    setCutterRun(false);
    const raf = requestAnimationFrame(() => setCutterRun(true));
    const timers: ReturnType<typeof setTimeout>[] = [];
    CENTER_FRACTIONS.forEach((frac, i) => {
      timers.push(
        setTimeout(() => {
          setDepths((d) => {
            const next = d.slice();
            next[i] = goal[i];
            return next;
          });
          flashNotch(i);
        }, Math.round(frac * CUT_MS))
      );
    });
    timers.push(
      setTimeout(() => {
        setCutterRun(false);
        settle();
      }, CUT_MS + 30)
    );

    return () => {
      cancelAnimationFrame(raf);
      timers.forEach(clearTimeout);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cutPhase]);

  return (
    <svg aria-hidden="true" viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} width="60" height="22" className="shrink-0">
      <circle cx={BOW_CX} cy={BOW_CY} r={BOW_R} fill="none" stroke="var(--foreground)" strokeWidth={3} />
      <g transform={`translate(${BOW_CX} ${BOW_CY})`}>
        <DeviceGlyph kind={device} />
      </g>
      <path d={bladePath(depths)} fill="var(--foreground)" />
      {NOTCH_X.map((x, i) => (
        <rect
          key={i}
          ref={(el) => {
            flashRefs.current[i] = el;
          }}
          x={x - 0.6}
          y={TOP - 4}
          width={1.2}
          height={BASE - TOP + 8}
          fill="var(--foreground)"
          opacity={0}
        />
      ))}
      <g className="ns-bc-cutter" data-run={cutterRun ? "go" : "idle"}>
        <line x1={X0} x2={X0} y1={TOP - 5} y2={BASE + 5} stroke="var(--foreground)" strokeWidth={1.4} />
        <circle cx={X0} cy={TOP - 7} r={2.2} fill="var(--foreground)" />
      </g>
    </svg>
  );
}

function PasskeyRow({
  row,
  armed,
  now,
  reducedMotion,
  onCutSettled,
  onFileSettled,
  onRequestRemove,
  onCancelArm,
}: {
  row: RowState;
  armed: boolean;
  now: number | null;
  reducedMotion: boolean;
  onCutSettled: (id: string) => void;
  onFileSettled: (id: string) => void;
  onRequestRemove: (id: string) => void;
  onCancelArm: (id: string) => void;
}) {
  const device = row.device ?? inferDevice(row.name);
  const busy = row.cutPhase !== "settled";
  const label = armed ? `Confirm remove ${row.name}` : `Remove ${row.name}`;

  return (
    <li
      data-collapsing={row.cutPhase === "exiting"}
      className="ns-bc-row"
    >
      <div className="ns-bc-row-clip">
        <div className="flex items-center gap-3 px-4 py-3">
          <KeyArt
            credentialId={row.id}
            device={device}
            cutPhase={row.cutPhase}
            reducedMotion={reducedMotion}
            onCutSettled={() => onCutSettled(row.id)}
            onFileSettled={() => onFileSettled(row.id)}
          />
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium text-foreground">{row.name}</p>
            <p className="mt-0.5 truncate font-mono text-xs text-ns-muted">
              Created {formatCreated(row.createdAt)} · {formatLastUsed(row.lastUsedAt, now)}
            </p>
          </div>
          <button
            type="button"
            className="ns-bc-btn shrink-0"
            data-armed={armed}
            disabled={busy}
            aria-label={label}
            onClick={() => onRequestRemove(row.id)}
            onBlur={() => onCancelArm(row.id)}
            onKeyDown={(e) => {
              if (e.key === "Escape" && armed) onCancelArm(row.id);
            }}
          >
            {armed ? "Confirm" : "Remove"}
          </button>
        </div>
      </div>
    </li>
  );
}

// ---- root ---------------------------------------------------------------

const DEVICE_POOL: Array<{ name: string; device: DeviceKind }> = [
  { name: "iPhone 16", device: "phone" },
  { name: "MacBook Pro", device: "laptop" },
  { name: "iPad Air", device: "tablet" },
  { name: "YubiKey 5C", device: "key" },
  { name: "Pixel 9", device: "phone" },
  { name: "Mac Studio", device: "desktop" },
];

function newPasskey(existingCount: number): Passkey {
  const pick = DEVICE_POOL[existingCount % DEVICE_POOL.length];
  const id =
    typeof crypto !== "undefined" && "randomUUID" in crypto
      ? crypto.randomUUID()
      : `pk-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  return { id, name: pick.name, device: pick.device, createdAt: Date.now(), lastUsedAt: null };
}

export function BittingCut({
  initialPasskeys,
  maxPasskeys = 6,
  ariaLabel = "Passkeys",
  onAdd,
  onRemove,
  className = "",
}: BittingCutProps) {
  const uid = useId();
  const reducedMotion = useReducedMotion();
  const [rows, setRows] = useState<RowState[]>(() =>
    (initialPasskeys ?? []).map((p) => ({ ...p, cutPhase: "settled" as CutPhase }))
  );
  const [armedId, setArmedId] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");
  const [now, setNow] = useState<number | null>(null);

  useEffect(() => {
    setNow(Date.now());
  }, []);

  const atMax = rows.length >= maxPasskeys;

  function handleAdd() {
    if (atMax) return;
    const pk = newPasskey(rows.length);
    setRows((rs) => [...rs, { ...pk, cutPhase: "entering" }]);
    onAdd?.(pk);
  }

  function handleCutSettled(id: string) {
    setRows((rs) => rs.map((r) => (r.id === id ? { ...r, cutPhase: "settled" } : r)));
  }

  function handleRequestRemove(id: string) {
    if (armedId === id) {
      setArmedId(null);
      setRows((rs) => rs.map((r) => (r.id === id ? { ...r, cutPhase: "filing" } : r)));
    } else {
      setArmedId(id);
    }
  }

  function handleCancelArm(id: string) {
    setArmedId((cur) => (cur === id ? null : cur));
  }

  function handleFileSettled(id: string) {
    setRows((rs) => rs.map((r) => (r.id === id ? { ...r, cutPhase: "exiting" } : r)));
    const delay = reducedMotion ? 0 : 260;
    setTimeout(() => {
      setRows((rs) => {
        const target = rs.find((r) => r.id === id);
        const next = rs.filter((r) => r.id !== id);
        if (target) {
          onRemove?.(id);
          setAnnounce(`Passkey ${target.name} removed, ${remainWord(next.length)}.`);
        }
        return next;
      });
    }, delay);
  }

  return (
    <div className={["ns-bc", className].join(" ")}>
      <style>{CSS}</style>

      <div className="flex items-center justify-between gap-3 pb-3">
        <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted">Sign-in methods</p>
        <div className="flex items-center gap-2">
          {atMax && <span className="font-mono text-[10px] text-ns-muted">Maximum reached</span>}
          <button
            type="button"
            className="ns-bc-btn ns-bc-add"
            disabled={atMax}
            aria-label="Add passkey"
            onClick={handleAdd}
          >
            Add passkey
          </button>
        </div>
      </div>

      <ul role="list" aria-label={ariaLabel} id={`${uid}-list`} className="ns-bc-list rounded-[12px] border border-border bg-background">
        {rows.length === 0 ? (
          <li className="px-4 py-6 text-center font-mono text-xs text-ns-muted">No passkeys enrolled.</li>
        ) : (
          rows.map((row) => (
            <PasskeyRow
              key={row.id}
              row={row}
              armed={armedId === row.id}
              now={now}
              reducedMotion={reducedMotion}
              onCutSettled={handleCutSettled}
              onFileSettled={handleFileSettled}
              onRequestRemove={handleRequestRemove}
              onCancelArm={handleCancelArm}
            />
          ))
        )}
      </ul>

      <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </div>
    </div>
  );
}

const CSS = `
.ns-bc-list{overflow:hidden}
.ns-bc-row{display:grid;grid-template-rows:1fr;opacity:1;transition:grid-template-rows 260ms cubic-bezier(.22,1,.36,1),opacity 260ms ease-out}
.ns-bc-row[data-collapsing="true"]{grid-template-rows:0fr;opacity:0}
.ns-bc-row-clip{overflow:hidden;min-height:0;transition:background-color 150ms ease-out}
.ns-bc-row-clip:hover{background:color-mix(in srgb, var(--foreground) 4%, transparent)}
.ns-bc-row + .ns-bc-row > .ns-bc-row-clip{border-top:1px solid var(--border)}
.ns-bc-cutter{opacity:0;transform:translateX(0)}
.ns-bc-cutter[data-run="go"]{opacity:1;transform:translateX(${X1 - X0}px);transition:transform ${CUT_MS}ms cubic-bezier(.4,0,.2,1),opacity 120ms ease-out}
.ns-bc-btn{display:inline-flex;height:26px;align-items:center;justify-content:center;border-radius:6px;padding:0 10px;border:1px solid var(--border);background:var(--background);color:var(--ns-muted);font-family:var(--font-geist-mono,ui-monospace,monospace);font-size:11px;transition:background-color 150ms ease-out,color 150ms ease-out,border-color 150ms ease-out,box-shadow 150ms ease-out}
.ns-bc-btn:hover:not(:disabled){color:var(--foreground);border-color:var(--foreground)}
.ns-bc-btn:disabled{opacity:0.45}
.ns-bc-btn:focus-visible{outline:2px solid var(--ns-accent);outline-offset:2px}
.ns-bc-btn[data-armed="true"]{color:var(--ns-accent);border-color:var(--ns-accent)}
.ns-bc-add{color:var(--foreground)}
@media (prefers-reduced-motion: reduce){
  .ns-bc-row,.ns-bc-cutter,.ns-bc-btn,.ns-bc-row-clip{transition:none!important}
}
`;

export default BittingCut;
Build spec

`<BittingCut initialPasskeys={Passkey[]} maxPasskeys? ariaLabel? onAdd? onRemove? className? />` renders a Settings > Sign-in methods list where every passkey draws its own key: a 112x40 inline SVG per row — a stroked ring bow (fill:none, 3px --foreground stroke, so the ring's centre reads as a literal hole) carrying a small device glyph (phone/laptop/tablet/desktop/hardware-key/generic, inferred from the row's name when `device` isn't set) drawn in --foreground strokes inside that hole, and a solid --foreground blade rendered as ONE closed SVG path whose bottom edge steps through six notch cells at one of five quantized depths. THE BITTING IS DETERMINISTIC: `bittingDepths(credentialId)` FNV-1a-hashes `credentialId#i` for i in 0..5 and takes `hash % 5` per notch — a pure function of the row's `id`, same output every call, every mount, forever. There is no `Math.random()` anywhere in that derivation; decorative randomized teeth are the explicitly forbidden alternative, because the whole point is that the SAME credential looks the SAME key on the next visit. ENROLLMENT: a passkey added at runtime (via the header's real 'Add passkey' button, which fabricates a fresh credential id from `crypto.randomUUID()` and stops offering itself once the list reaches `maxPasskeys`, default 6) mounts with cutPhase 'entering' and plays a 700ms one-shot pass: a cutter graphic (a short vertical line with a small circular head, hidden at rest) sweeps smoothly from the blade's shoulder to its tip on a CSS transform transition, while each of the six notches STEPS — not tweens — to its final hashed depth via a `setTimeout` scheduled at the moment the sweep's linear position crosses that notch's centre (a cutting wheel commits a depth as it passes, it does not interpolate one in), each step also flashing a 1px --foreground rect at that notch's x-position (direct ref style write: opacity 1 with transition:none, then next frame opacity 0 over 160ms — never React state, so the flash can't itself cause a re-render loop). Passkeys present in `initialPasskeys` at mount start at cutPhase 'settled' with their target depths already drawn — no entrance animation, because for a credential enrolled before this page loaded, that key was already cut; only a genuinely NEW enrollment plays the pass. REMOVAL runs the identical mechanism inverted: cutPhase 'filing' sweeps the same cutter across the same blade, but each notch it passes steps back to depth 0 (flat/blank) instead of toward a target — 'flattening the profile to blank' — and once every notch is blank the row transitions to 'exiting', collapsing via `grid-template-rows: 1fr -> 0fr` + opacity over 260ms (mirrors this repo's existing arm-then-confirm row-eviction pattern), after which it is spliced out of state, `onRemove(id)` fires, and a `role=status aria-live=polite` region announces 'Passkey {name} removed, {n remain}' (singular '1 remains', otherwise '{n} remain') — the only thing that ever announces; adding is silent. REMOVE ITSELF is a real per-row button that arms on first press (`data-armed`, accessible name flips from 'Remove {name}' to 'Confirm remove {name}', border/text tint to --ns-accent — the only place --ns-accent appears, since arming is an active interaction state, not decoration) and commits on a second press on the SAME button; losing focus (`onBlur`) or pressing Escape while armed reverts it unconfirmed. The button is `disabled` for the whole entering/filing/exiting span of its own row, so it cannot be re-armed mid-cut or double-filed. TEXT: every row shows the device name, 'Created {Month Day}' and a last-used phrase ('Last used today' / 'yesterday' / 'N days ago' / 'N weeks ago' / a date, or 'Never used' for a freshly enrolled key with no lastUsedAt) as plain sibling text next to the art — the key SVG is `aria-hidden`, decorative, and never the row's sole identifier; a screen reader gets the same facts as the graphic conveys visually. The list is a real `<ul role="list">`/`<li>` structure with an accessible name. Last-used text follows this repo's SSR-safe pattern: `now` starts `null` and is set from `Date.now()` inside a mount effect, so the very first client render never reads the clock during render and can't mismatch a server pass; before that effect fires the row shows an inert '…' placeholder for last-used only (created-date, month/day only, needs no clock and is never a placeholder). REDUCED MOTION (`matchMedia` with a live change listener) makes both the cutting and filing passes land in a single synchronous step — full target depths or full blank depths applied immediately, cutter never becomes visible, row collapse has zero transition duration — so an assistive-tech user or anyone with the OS preference set never waits on an animation to see a stable, legible, already-finished key or an already-gone row. Colors are `--background --foreground --ns-muted --border --ns-accent` only, no hex/rgb/hsl literals anywhere including inside the SVG paths. DOM + SVG + CSS, no canvas, zero dependencies.

Props

PropTypeDefaultDescription
initialPasskeys?Passkey[]the starting set of enrolled passkeys, rendered already-cut (no entrance animation)
maxPasskeys?number6stop offering "Add passkey" once the list reaches this size. default 6
ariaLabel?string"Passkeys"accessible name for the list. default "Passkeys"
onAdd?(passkey: Passkey) => voidfired once a newly-generated passkey has been appended
onRemove?(id: string) => voidfired once a passkey has finished its removal (file + collapse) pass
className?stringextra classes merged onto the rendered root element