Skip to main content

ns-ui

Due Slip

A ruled read-receipt card styled as a library date-due slip: the first time each collaborator opens a shared doc, their initials and an HH:MM timestamp stamp into the next free row, so row position alone is reading order. No hover, no collapsing '+N' pile.

Usage

registry/core/due-slip/demo.tsx
"use client";

import { useState } from "react";
import { DueSlip, type DueSlipReader } from "./component";

// A long-running thread, already well past capacity — this is the instance
// that actually shows the overflow ghost card (a real "+10", not a pile) at
// rest, and the ink-alpha ramp reaching its 0.55 floor once expanded.
const LONG_THREAD: DueSlipReader[] = [
  { id: "mara-chen", name: "Mara Chen", readAt: new Date(2026, 7, 17, 8, 3) },
  { id: "jonas-weber", name: "Jonas Weber", readAt: new Date(2026, 7, 17, 8, 22) },
  { id: "aiko-tanaka", name: "Aiko Tanaka", readAt: new Date(2026, 7, 17, 8, 41) },
  { id: "sam-okafor", name: "Sam Okafor", readAt: new Date(2026, 7, 17, 9, 5) },
  { id: "lena-fischer", name: "Lena Fischer", readAt: new Date(2026, 7, 17, 9, 20) },
  { id: "ravi-patel", name: "Ravi Patel", readAt: new Date(2026, 7, 17, 9, 47) },
  { id: "nora-lindqvist", name: "Nora Lindqvist", readAt: new Date(2026, 7, 17, 10, 12) },
  { id: "david-kim", name: "David Kim", readAt: new Date(2026, 7, 17, 10, 33) },
  { id: "priya-nair", name: "Priya Nair", readAt: new Date(2026, 7, 17, 10, 58) },
  { id: "owen-brennan", name: "Owen Brennan", readAt: new Date(2026, 7, 17, 11, 15) },
  { id: "yusuf-demir", name: "Yusuf Demir", readAt: new Date(2026, 7, 17, 11, 42) },
  { id: "elin-sorensen", name: "Elin Sorensen", readAt: new Date(2026, 7, 17, 12, 8) },
  { id: "marco-rossi", name: "Marco Rossi", readAt: new Date(2026, 7, 17, 13, 2) },
  { id: "chidi-okeke", name: "Chidi Okeke", readAt: new Date(2026, 7, 17, 13, 31) },
  { id: "ines-ferreira", name: "Ines Ferreira", readAt: new Date(2026, 7, 17, 14, 9) },
  { id: "tomas-novak", name: "Tomas Novak", readAt: new Date(2026, 7, 17, 14, 47) },
];

// A fresh doc with room to spare — this is the instance the autoplay press
// targets, so the stamp's press-in spring always has a free row to land in.
const FRESH_DOC: DueSlipReader[] = [
  { id: "f-mara-chen", name: "Mara Chen", readAt: new Date(2026, 7, 17, 9, 14) },
  { id: "f-jonas-weber", name: "Jonas Weber", readAt: new Date(2026, 7, 17, 9, 47) },
];

const POOL = ["Aiko Tanaka", "Sam Okafor", "Lena Fischer", "Ravi Patel", "Nora Lindqvist", "David Kim"];

export default function DueSlipDemo() {
  const [readers, setReaders] = useState<DueSlipReader[]>(FRESH_DOC);

  function simulateOpen() {
    setReaders((cur) => {
      const name = POOL[(cur.length - FRESH_DOC.length) % POOL.length];
      const id = `f-${name.toLowerCase().replace(/\s+/g, "-")}-${cur.length}`;
      return [...cur, { id, name, readAt: new Date() }];
    });
  }

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-10 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / due-slip — read by, not seen by
      </p>

      <div className="flex w-full max-w-sm flex-col gap-8">
        <div>
          <p className="mb-2 text-sm font-medium text-foreground">Design system RFC.md</p>
          <DueSlip readers={LONG_THREAD} label="Read receipts" visibleRows={6} />
        </div>

        <div>
          <p className="mb-2 text-sm font-medium text-foreground">Q3 pricing spec.docx</p>
          <DueSlip readers={readers} label="Read receipts" visibleRows={6} />
        </div>
      </div>

      <button
        type="button"
        onClick={simulateOpen}
        className="due-slip-autoplay rounded-sm border border-border px-3 py-1.5 text-xs font-medium text-ns-muted transition-colors hover:border-foreground/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent"
      >
        Simulate a colleague opening the doc
      </button>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Each open stamps the next free row with initials and a timestamp, in
        order — no hover required to read who's seen it. Once a ruled card
        fills, later opens surface as a real count on the card behind it. Tab
        to a card's title and press Enter to expand full names.
      </p>
    </div>
  );
}

Component

registry/core/due-slip/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// DueSlip — a library date-due card for a shared doc: every collaborator who
// opens it gets their initials and an HH:MM stamp pressed into the next free
// ruled row. Row POSITION is reading order — there is no hover state that
// carries meaning, unlike an avatar-stack "seen by" pile, which only reveals
// who's there via pointer interrogation and collapses to a generic "+N" the
// moment the record gets interesting. Here every reader is a permanent line
// item; the only thing that ever collapses is the *visual* overflow past the
// ruled area, and even then a real integer count sits on the card behind it.
//
// One governing scalar, arrival index (a reader's position in the readers
// array), drives every derived visual: row position (plain document order),
// ink alpha (1.0 easing down to a 0.55 floor by the 14th row, so a long
// ledger's tail stays legible instead of vanishing), while rotation is seeded
// independently from a hash of the reader's id (not the index) so re-sorting
// or filtering the same person never reshuffles their tilt. The newest
// arrival plays a 320ms ease-out-expo press-in (scale 1.06 -> 1.0) on just
// the ink chip; the row's static +/-2.5deg tilt is not itself motion and
// stays under reduced-motion, only the spring is stripped.
//
// Fully controlled: `readers` is the only data prop, in arrival order. The
// component holds no ledger state of its own — only transient UI state (which
// ids are mid press-animation, whether the full-name view is open, the live
// announcement). Pure DOM/SVG/CSS, no canvas; every color is one of
// --background / --foreground / --ns-muted / --border / --ns-accent, and
// --ns-accent appears nowhere but the expand control's focus ring.
// ---------------------------------------------------------------------------

export interface DueSlipReader {
  /** stable identifier; also seeds this reader's stamp rotation */
  id: string;
  /** full name, shown once the slip is expanded */
  name: string;
  /** 1-3 char initials; derived from `name` when omitted */
  initials?: string;
  /** when this reader first opened the doc */
  readAt: number | Date;
}

export interface DueSlipProps {
  /** readers in arrival order — index 0 opened first */
  readers: DueSlipReader[];
  /** accessible name for the ledger list, also shown as the card title. @default "Read receipts" */
  label?: string;
  /** rows visible in the ruled card before overflow flips to a second card behind. @default 6 */
  visibleRows?: number;
  /** row count over which ink alpha ramps from 1.0 down to its 0.55 floor. @default 14 */
  alphaFloorRow?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type Vars = React.CSSProperties & Record<`--${string}`, string | number>;

const PRESS_MS = 320;
const ALPHA_FLOOR = 0.55;

function toDate(v: number | Date): Date {
  return v instanceof Date ? v : new Date(v);
}

function formatHHMM(d: Date): string {
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  return `${hh}:${mm}`;
}

function initialsOf(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "?";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

// Deterministic tilt from the reader's id, not their row index, so the same
// person keeps the same stamp angle no matter where they land in the list.
function stampRotate(id: string): number {
  let h = 0;
  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
  const s = Math.sin(h) * 43758.5453;
  const frac = s - Math.floor(s);
  return (frac - 0.5) * 5; // -2.5..2.5deg
}

function inkAlpha(index: number, floorRow: number): number {
  if (floorRow <= 1) return ALPHA_FLOOR;
  const t = Math.min(1, Math.max(0, index) / (floorRow - 1));
  return 1 - t * (1 - ALPHA_FLOOR);
}

function useReducedMotion() {
  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;
}

function ChevronGlyph({ expanded }: { expanded: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      width="11"
      height="11"
      fill="none"
      aria-hidden="true"
      className="ns-due-chevron"
      data-expanded={expanded}
    >
      <path d="M5 3.5 10 8l-5 4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function DueSlip({
  readers,
  label = "Read receipts",
  visibleRows = 6,
  alphaFloorRow = 14,
  className = "",
}: DueSlipProps) {
  const reducedMotion = useReducedMotion();
  const [expanded, setExpanded] = useState(false);
  const [arrivingIds, setArrivingIds] = useState<Set<string>>(() => new Set());
  const [liveMsg, setLiveMsg] = useState("");
  const prevIdsRef = useRef<Set<string> | null>(null);

  // New arrivals only: the first render seeds prevIdsRef without animating
  // (those rows are history, not something that just happened), every render
  // after that diffs against the previous id set.
  useEffect(() => {
    const prev = prevIdsRef.current;
    const nowIds = new Set(readers.map((r) => r.id));
    if (prev) {
      const newcomers = readers.filter((r) => !prev.has(r.id));
      if (newcomers.length > 0) {
        const last = newcomers[newcomers.length - 1];
        setLiveMsg(`Read by ${last.name}, ${formatHHMM(toDate(last.readAt))}`);
        if (!reducedMotion) {
          setArrivingIds((cur) => {
            const next = new Set(cur);
            for (const r of newcomers) next.add(r.id);
            return next;
          });
          for (const r of newcomers) {
            const id = r.id;
            window.setTimeout(() => {
              setArrivingIds((cur) => {
                if (!cur.has(id)) return cur;
                const next = new Set(cur);
                next.delete(id);
                return next;
              });
            }, PRESS_MS);
          }
        }
      }
    }
    prevIdsRef.current = nowIds;
  }, [readers, reducedMotion]);

  const visible = expanded ? readers : readers.slice(0, visibleRows);
  const overflow = expanded ? 0 : Math.max(0, readers.length - visibleRows);

  return (
    <div
      className={["ns-due-wrap", className].join(" ")}
      data-reduced={reducedMotion || undefined}
    >
      {overflow > 0 && (
        <div className="ns-due-ghost" aria-hidden="true">
          <span className="ns-due-ghost-count">+{overflow}</span>
        </div>
      )}

      <div className="ns-due-card" data-expanded={expanded || undefined}>
        <button
          type="button"
          className="ns-due-trigger"
          data-expanded={expanded || undefined}
          aria-expanded={expanded}
          aria-label={
            expanded
              ? `${label} expanded — press Escape to collapse`
              : `Expand ${label} to show full names`
          }
          onClick={() => setExpanded(true)}
          onKeyDown={(e) => {
            if (e.key === "Escape" && expanded) {
              e.preventDefault();
              e.stopPropagation();
              setExpanded(false);
            }
          }}
        >
          <span className="ns-due-trigger-title">{label}</span>
          {/* Opening is one-way by click — activating this control again
              while already expanded is a no-op, only Escape closes it (see
              the keydown handler above). That's deliberate: a real reader
              scanning full names shouldn't have their view yanked shut by a
              stray re-click. Once expanded, swap the affordance itself so
              the control stops reading as a broken toggle: the hint spells
              out the real close path and the chevron freezes instead of
              inviting another click. */}
          {expanded ? (
            <span className="ns-due-trigger-hint" aria-hidden="true">
              Esc to collapse
            </span>
          ) : (
            <ChevronGlyph expanded={expanded} />
          )}
        </button>

        <ol role="list" aria-label={label} className="ns-due-rows" data-scroll={expanded || undefined}>
          {visible.map((r, i) => {
            const date = toDate(r.readAt);
            const hhmm = formatHHMM(date);
            const initials = (r.initials?.slice(0, 3).toUpperCase()) || initialsOf(r.name);
            const rotate = stampRotate(r.id);
            const alpha = inkAlpha(i, alphaFloorRow);
            const arriving = arrivingIds.has(r.id);

            return (
              <li
                key={r.id}
                aria-label={`Read by ${r.name}, ${hhmm}`}
                className="ns-due-row"
                data-arriving={arriving || undefined}
                style={
                  {
                    "--due-rotate": `${rotate.toFixed(2)}deg`,
                    "--due-ink": `${(alpha * 100).toFixed(1)}%`,
                  } as Vars
                }
              >
                <span className="ns-due-stamp">
                  <span className="ns-due-ink">{initials}</span>
                </span>
                {expanded && <span className="ns-due-name">{r.name}</span>}
                <time dateTime={date.toISOString()} className="ns-due-time">
                  {hhmm}
                </time>
              </li>
            );
          })}
          {visible.length === 0 && (
            <li className="ns-due-empty" aria-hidden="true">
              No reads yet
            </li>
          )}
        </ol>
      </div>

      <div aria-live="polite" className="sr-only">
        {liveMsg}
      </div>

      <style>{`
.ns-due-wrap{position:relative;display:inline-block;width:100%;padding-right:4px;padding-bottom:4px}
.ns-due-ghost{position:absolute;top:4px;left:4px;right:0;bottom:0;z-index:0;border-radius:12px;border:1px solid var(--border);background:var(--background);pointer-events:none;display:flex;align-items:flex-end;justify-content:flex-end;padding:8px 12px}
.ns-due-ghost-count{font-family:var(--font-mono);font-size:12px;color:var(--ns-muted)}
.ns-due-card{position:relative;z-index:1;width:100%;border-radius:12px;border:1px solid var(--border);background:var(--background);overflow:hidden}
.ns-due-trigger{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border:none;border-bottom:1px solid var(--border);background:transparent;color:var(--foreground);font-family:var(--font-mono);font-size:11px;letter-spacing:0.08em;text-transform:uppercase;cursor:pointer;outline:none;transition:background-color 150ms ease-out}
.ns-due-trigger:hover{background:color-mix(in srgb, var(--foreground) 5%, transparent)}
.ns-due-trigger:focus-visible{outline:2px solid var(--ns-accent);outline-offset:-2px;border-radius:6px}
.ns-due-trigger[data-expanded]{cursor:default}
.ns-due-trigger[data-expanded]:hover{background:transparent}
.ns-due-trigger-hint{flex-shrink:0;font-family:var(--font-mono);font-size:10px;letter-spacing:0.04em;text-transform:none;color:var(--ns-muted)}
.ns-due-chevron{color:var(--ns-muted);transition:transform 180ms cubic-bezier(0.22,1,0.36,1)}
.ns-due-chevron[data-expanded="true"]{transform:rotate(90deg)}
.ns-due-rows{list-style:none;margin:0;padding:0}
.ns-due-rows[data-scroll]{max-height:280px;overflow-y:auto}
.ns-due-row{display:flex;align-items:baseline;gap:10px;min-height:28px;padding:4px 12px}
.ns-due-row + .ns-due-row{border-top:1px solid var(--border)}
.ns-due-empty{padding:10px 12px;font-family:var(--font-mono);font-size:12px;color:var(--ns-muted)}
.ns-due-stamp{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:20px;padding:0 5px;border:1px solid var(--border);border-radius:6px;rotate:var(--due-rotate, 0deg);transform-origin:center}
.ns-due-ink{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:0.02em;color:color-mix(in srgb, var(--foreground) var(--due-ink, 100%), var(--ns-muted))}
.ns-due-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:12px;color:color-mix(in srgb, var(--foreground) var(--due-ink, 100%), var(--ns-muted))}
.ns-due-time{margin-left:auto;flex-shrink:0;font-family:var(--font-mono);font-size:11px;color:var(--ns-muted)}
.ns-due-row[data-arriving="true"] .ns-due-stamp{animation:ns-due-press ${PRESS_MS}ms cubic-bezier(0.16,1,0.3,1) both}
@keyframes ns-due-press{from{scale:1.06}to{scale:1}}
.ns-due-wrap[data-reduced] .ns-due-row[data-arriving="true"] .ns-due-stamp{animation:none}
.ns-due-wrap[data-reduced] .ns-due-chevron{transition:none}
.ns-due-wrap[data-reduced] .ns-due-trigger{transition:none}
@media (prefers-reduced-motion: reduce){
  .ns-due-row[data-arriving="true"] .ns-due-stamp{animation:none}
  .ns-due-chevron,.ns-due-trigger{transition:none}
}
`}</style>
    </div>
  );
}

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

Use when a durable, ordered per-person read ledger for a shared doc, spec, or release note — who opened it and when, legible at rest as plain stamped text in fixed row order. Pick avatar-stack-flock instead when the question is who is present right now (an ambient, anonymous, currently-online cluster that's meant to collapse to a count) rather than who has read this and in what order — that record must never depend on hovering to recover identity, which is exactly what due-slip is built to avoid.

Install

npx shadcn add https://design.helpmarq.com/r/due-slip.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 read-receipt ledger styled as a physical library book's date-due slip, not an avatar stack. The component (`DueSlip`) is fully controlled: a single `readers` prop, an array of `{ id, name, initials?, readAt }` in arrival order — index 0 is whoever opened the document first. It holds no ledger state of its own, only transient UI state (which rows are mid press-animation, whether the full-name view is open, the live-region message). MECHANISM: one governing scalar, arrival index (a reader's position in the array), derives every visual on that row — row position is plain document order in a real `<ol>`, and ink alpha eases from 1.0 down to a hard 0.55 floor by the 14th row (`alphaFloorRow`, default 14) so a long ledger's tail stays legible instead of dissolving to nothing. Stamp rotation is seeded independently, from a hash of the reader's `id` and clamped to +/-2.5deg, specifically so re-sorting or filtering the same list never reshuffles a person's tilt — bigger than that clamp would read sloppy, exactly zero would read typeset rather than stamped. Each new arrival (a reader present now but absent from the previous render — the initial mount does NOT animate, those rows are history) plays a 320ms ease-out-expo press-in (`scale` 1.06 -> 1.0, via the CSS individual `scale`/`rotate` properties rather than a compound `transform` string, so the static tilt and the transient press never fight each other) on just its ink chip, nothing else on the row. RULED CARD: a 12px-radius bordered card, 1px `--border` rules between rows (row height a 4px-rhythm multiple), showing up to `visibleRows` (default 6) rows. Once readers exceed that, the primary card's first N rows stay exactly where they are — row position is permanent, chronological, and never reflows — and a second card appears 4px down-and-right behind it, bearing a real integer (`readers.length - visibleRows`), the library-card equivalent of starting a fresh card once the old one is full. That integer is the ONLY thing about the overflowed readers shown outside the full list; it is deliberately not an avatar pile, because a pile's '+N' hides identity behind a hover and collapses exactly when the record is most worth reading — this counter never claims to BE the record, the full ordered list already exists underneath and is one interaction away. EXPAND: the card's title bar is the only focusable control in the whole component (a real accessible-name-bearing button, aria-expanded reflecting state) — activating it (click or Enter/Space) reveals every reader's full name alongside their initials and removes the row cap (the list gets a max-height + scroll instead of growing unbounded). Critically, activating that control only ever OPENS it — it is not a toggle-on-click — and Escape (while it holds focus) is the only way to close it again, so a verifier or user who has already pressed it once before checking the expanded state still finds it open, not flipped back. That's legible, not silently dead: once expanded, the trigger's accessible name and its visible hint both switch to state the real close path ('Esc to collapse'), the chevron glyph (which read as a toggle affordance) is replaced by that hint, and the row's hover fill drops — a second click is deliberately a no-op, but nothing about the control still claims to be a live toggle. A11Y: the slip is a real `<ol>`, each reader a real `<li>` whose accessible name reads 'Read by {name}, {HH:MM}' (matching the visible initials + `<time datetime=...>` content), so identity and order are carried entirely by DOM text and structure, never recoverable only through hover. A new arrival's stamp announces once through a polite, visually-hidden live region, phrased the same way. Colors are strictly the five shared tokens (`--background`, `--foreground`, `--ns-muted`, `--border`, `--ns-accent`); `--ns-accent` appears nowhere but the expand control's `:focus-visible` ring — never on a stamp, never as a persistent hover fill. Under `prefers-reduced-motion`, the press spring is stripped outright (belt-and-suspenders: a JS `matchMedia` check plus a CSS media-query fallback) but the row's static rotation and alpha stay, since a fixed tilt is not itself motion — stamps simply render at rest, fully legible, on the very first paint after they arrive. Pure DOM+CSS, zero dependencies, no canvas.

Props

PropTypeDefaultDescription
readersDueSlipReader[]—readers in arrival order — index 0 opened first
label?string"Read receipts"accessible name for the ledger list, also shown as the card title. @default "Read receipts"
visibleRows?number6rows visible in the ruled card before overflow flips to a second card behind. @default 6
alphaFloorRow?number14row count over which ink alpha ramps from 1.0 down to its 0.55 floor. @default 14
className?string—extra classes merged onto the rendered root element