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.
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.jsonSource
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>
);
}
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
| Prop | Type | Default | Description |
|---|---|---|---|
| readers | DueSlipReader[] | — | 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? | number | 6 | rows visible in the ruled card before overflow flips to a second card behind. @default 6 |
| alphaFloorRow? | number | 14 | row 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 |