Skip to main content

ns-ui

Routing Slip

A multi-party approval ledger styled as an interoffice routing slip: names print in fixed routed order, signing presses an ink chop beside your name, and signing out of turn lands visibly skewed with a written note rather than being hidden or blocked.

Use when multi-party sign-off where routed order and actual signing order can diverge and that divergence is itself the thing being recorded — a doc, PR, or release note with a fixed approver chain, a quorum gate, and a Publish action that stays blocked with a named reason until enough of the chain has signed. Pick signature-consent instead for a single-user consent ceremony (one signer, one drawn or typed mark, Confirm unlocks once); routing-slip has no drawing surface and no single signer — it exists specifically to carry N approvers, a fixed sequence, and whether reality matched it, which a single-signer ceremony has no state to represent.

Install

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

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

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

// ---------------------------------------------------------------------------
// RouteSlip — a multi-party approval ledger styled as an interoffice routing
// slip, not an avatar-stack "seen by" row. Approver rows render in fixed
// ROUTED order and never reorder themselves; that order is set once from the
// `approvers` prop and is the spine every other visual hangs off. Signing is
// a real, irreversible action scoped to `currentApproverId`'s own row — no
// other row ever renders a Sign control. What distinguishes this from a
// checkmark list is that signing out of turn is explicitly PERMITTED: a
// signature is flagged "out of turn" whenever, at final state, some earlier
// -routed approver's slot is still empty or was filled later than this one
// (computed purely from routed index + signed timestamp, not from the order
// clicks happened to arrive in). An in-turn chop settles at a plain 3deg
// tilt; an out-of-turn chop lands with an 8deg skew on top of that tilt —
// large enough to read as irregular at a glance, small enough to stay
// legible — and the row also carries a plain-text "out of turn" note, so the
// distinction never depends on noticing the skew.
//
// One governing scalar — signed count over `quorum` — derives the progress
// line, Publish's aria-disabled state + written reason, and the crawling
// underline under whichever unsigned row is currently "Now" (the first gap
// in routed order, independent of who's allowed to fill it). `quorum` can be
// less than the full roster: publish can unblock before every name is
// stamped, which is a different claim than "everyone signed."
//
// Pure DOM + CSS: a real <table> (approver / role / status / time columns,
// <caption>, <th scope="col">), a polite aria-live region announcing
// "{n} of {total} signed; waiting on {role}", and every color drawn from
// --background / --foreground / --ns-muted / --border / --ns-accent.
// --ns-accent appears nowhere but the enabled Sign/Publish buttons' own
// hover/focus/active states — never on a chop, never as a fill elsewhere.
// ---------------------------------------------------------------------------

export interface RouteSlipApprover {
  /** stable identifier */
  id: string;
  /** printed name */
  name: string;
  /** short role/team label, e.g. "Legal" — this is what Publish's reason and
   * the aria-live announcement name when this approver is next */
  role: string;
}

export interface RouteSlipSignature {
  approverId: string;
  /** when this approver actually signed */
  at: number | Date;
}

export interface RouteSlipProps {
  /** the approver chain in ROUTED order — fixed row order, index 0 is first
   * in sequence. This order never changes regardless of actual signing order. */
  approvers: RouteSlipApprover[];
  /** seed one or more approvers as already signed, in whatever order they
   * actually signed (not necessarily routed order) — out-of-turn flags are
   * derived from this at every render */
  initialSignatures?: RouteSlipSignature[];
  /** signatures required before Publish unblocks. Clamped to
   * [1, approvers.length]. @default approvers.length */
  quorum?: number;
  /** the approver whose row gets a real Sign button — every other row is
   * read-only regardless of signed state */
  currentApproverId?: string;
  /** label for the document being routed, shown as the card title and in
   * the table's accessible caption */
  docLabel?: string;
  /** called once per successful sign, with the approver id and timestamp */
  onSign?: (approverId: string, at: number) => void;
  /** called once, when Publish is activated at or above quorum */
  onPublish?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type Sig = { approverId: string; at: number };

const CHOP_MS = 260;

function toMs(v: number | Date): number {
  return typeof v === "number" ? v : v.getTime();
}

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

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;
}

// A wall-clock stamp is the one thing here that can legitimately differ
// between the server render and the client one (locale/timezone), same
// reasoning as approval-inline-diff's TimeStamp — suppressHydrationWarning
// is correct, not a bug being papered over.
function TimeStamp({ ts }: { ts: number }) {
  const label = new Date(ts).toLocaleTimeString(undefined, {
    hour: "numeric",
    minute: "2-digit",
    second: "2-digit",
  });
  return <span suppressHydrationWarning>{label}</span>;
}

export function RouteSlip({
  approvers,
  initialSignatures,
  quorum,
  currentApproverId,
  docLabel = "Routing slip",
  onSign,
  onPublish,
  className = "",
}: RouteSlipProps) {
  const uid = useId().replace(/:/g, "");
  const captionId = `rs-caption-${uid}`;
  const reasonId = `rs-reason-${uid}`;

  const reducedMotion = useReducedMotion();

  const [signatures, setSignatures] = useState<Sig[]>(() =>
    (initialSignatures ?? []).map((s) => ({ approverId: s.approverId, at: toMs(s.at) }))
  );
  const [published, setPublished] = useState(false);
  const [arrivingId, setArrivingId] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");
  const arriveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const prevSignedCount = useRef(signatures.length);
  const prevPublished = useRef(false);

  useEffect(() => () => clearTimeout(arriveTimer.current), []);

  const quorumN = useMemo(() => {
    const total = approvers.length || 1;
    const raw = quorum ?? total;
    return Math.min(total, Math.max(1, raw));
  }, [quorum, approvers.length]);

  const signedMap = useMemo(() => {
    const m = new Map<string, number>();
    for (const s of signatures) m.set(s.approverId, s.at);
    return m;
  }, [signatures]);

  // Out-of-turn is derived from final state alone: approver i is out of turn
  // if some earlier-routed approver j<i is still unsigned, or signed later
  // than i did. Row 0 can never be out of turn — there is no one earlier to
  // have skipped ahead of.
  const outOfTurn = useMemo(() => {
    const flags = new Array<boolean>(approvers.length).fill(false);
    for (let i = 0; i < approvers.length; i++) {
      const at = signedMap.get(approvers[i].id);
      if (at === undefined) continue;
      for (let j = 0; j < i; j++) {
        const otherAt = signedMap.get(approvers[j].id);
        if (otherAt === undefined || otherAt > at) {
          flags[i] = true;
          break;
        }
      }
    }
    return flags;
  }, [approvers, signedMap]);

  const nextIndex = useMemo(
    () => approvers.findIndex((a) => !signedMap.has(a.id)),
    [approvers, signedMap]
  );
  const nextApprover = nextIndex >= 0 ? approvers[nextIndex] : null;

  const signedCount = signatures.length;
  const quorumMet = signedCount >= quorumN;
  const total = approvers.length;

  // Announce only on genuine transitions (new sign / publish), never on the
  // initial seeded mount — matches due-slip's newcomer-only announce pattern.
  useEffect(() => {
    if (signedCount > prevSignedCount.current) {
      const msg = nextApprover
        ? `${signedCount} of ${total} signed; waiting on ${nextApprover.role}.`
        : `${signedCount} of ${total} signed; all signed.`;
      setAnnounce(quorumMet ? `${msg} Publish unlocked.` : msg);
    }
    prevSignedCount.current = signedCount;
  }, [signedCount, total, nextApprover, quorumMet]);

  useEffect(() => {
    if (published && !prevPublished.current) setAnnounce("Published.");
    prevPublished.current = published;
  }, [published]);

  function handleSign() {
    if (!currentApproverId || published) return;
    if (signedMap.has(currentApproverId)) return;
    const at = Date.now();
    setSignatures((prev) => [...prev, { approverId: currentApproverId, at }]);
    onSign?.(currentApproverId, at);
    if (!reducedMotion) {
      setArrivingId(currentApproverId);
      clearTimeout(arriveTimer.current);
      arriveTimer.current = setTimeout(() => setArrivingId(null), CHOP_MS);
    }
  }

  function handlePublish() {
    if (published || !quorumMet) return;
    setPublished(true);
    onPublish?.();
  }

  const remaining = Math.max(0, quorumN - signedCount);
  let reasonText: string;
  if (published) {
    reasonText = "Published.";
  } else if (quorumMet) {
    reasonText = "Quorum met — ready to publish.";
  } else if (nextApprover) {
    reasonText = `Publish disabled — needs ${remaining} more signature${
      remaining === 1 ? "" : "s"
    }, waiting on ${nextApprover.role}.`;
  } else {
    reasonText = "Publish disabled — awaiting signatures.";
  }

  const publishDisabled = !quorumMet || published;

  return (
    <div className={`w-full max-w-xl rounded-md border border-border bg-background ${className}`}>
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
        <div className="min-w-0">
          <p className="truncate text-sm font-medium text-foreground">{docLabel}</p>
          <p className="font-mono text-[11px] text-ns-muted">
            Quorum {quorumN} of {total}
          </p>
        </div>
        <p className="shrink-0 font-mono text-xs tabular-nums text-ns-muted">
          {signedCount} of {total} signed
        </p>
      </div>

      <div className="overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <caption id={captionId} className="sr-only">
            Approver routing slip for {docLabel}, in routed order
          </caption>
          <thead>
            <tr className="border-b border-border">
              <th scope="col" className="px-4 py-2 text-left font-mono text-[10px] uppercase tracking-wide text-ns-muted">
                Approver
              </th>
              <th scope="col" className="px-3 py-2 text-left font-mono text-[10px] uppercase tracking-wide text-ns-muted">
                Role
              </th>
              <th scope="col" className="px-3 py-2 text-left font-mono text-[10px] uppercase tracking-wide text-ns-muted">
                Status
              </th>
              <th scope="col" className="px-4 py-2 text-right font-mono text-[10px] uppercase tracking-wide text-ns-muted">
                Time
              </th>
            </tr>
          </thead>
          <tbody>
            {approvers.map((a, i) => {
              const at = signedMap.get(a.id);
              const isSigned = at !== undefined;
              const isNext = !isSigned && i === nextIndex;
              const isOutOfTurn = isSigned && outOfTurn[i];
              const showSign = a.id === currentApproverId && !isSigned && !published;
              const arriving = arrivingId === a.id;

              return (
                <tr key={a.id} className="border-b border-border last:border-b-0">
                  <td className="px-4 py-3 align-top">
                    <div className="flex items-center gap-2.5">
                      <span className="text-foreground">{a.name}</span>
                      <span
                        aria-hidden="true"
                        className="ns-rs-box"
                        data-signed={isSigned || undefined}
                        data-out-of-turn={isOutOfTurn || undefined}
                        data-arriving={arriving || undefined}
                      >
                        {isSigned ? initialsOf(a.name) : null}
                      </span>
                    </div>
                    {isOutOfTurn && (
                      <p
                        data-slip-out-of-turn
                        className="mt-1 font-mono text-[10px] uppercase tracking-wide text-ns-muted"
                      >
                        out of turn
                      </p>
                    )}
                  </td>
                  <td className="px-3 py-3 align-top text-ns-muted">{a.role}</td>
                  <td className="px-3 py-3 align-top">
                    {isSigned ? (
                      <span className="font-mono text-xs text-foreground">Signed</span>
                    ) : showSign ? (
                      <button
                        type="button"
                        data-slip-sign
                        onClick={handleSign}
                        className="ns-rs-btn"
                      >
                        Sign as {a.name}
                      </button>
                    ) : isNext ? (
                      <span className="ns-rs-now">Now</span>
                    ) : (
                      <span className="font-mono text-xs text-ns-muted">Awaiting</span>
                    )}
                  </td>
                  <td className="px-4 py-3 align-top text-right">
                    {isSigned ? (
                      <time
                        dateTime={new Date(at as number).toISOString()}
                        suppressHydrationWarning
                        className="font-mono text-xs tabular-nums text-ns-muted"
                      >
                        <TimeStamp ts={at as number} />
                      </time>
                    ) : (
                      <span aria-hidden="true" className="font-mono text-xs text-ns-muted">
                        —
                      </span>
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <div className="border-t border-border px-4 py-3">
        <button
          type="button"
          data-slip-publish
          aria-disabled={publishDisabled}
          aria-describedby={reasonId}
          data-disabled={publishDisabled || undefined}
          onClick={handlePublish}
          className="ns-rs-btn w-full py-2 text-xs uppercase tracking-wide"
        >
          {published ? "Published" : "Publish"}
        </button>
        <p id={reasonId} className="mt-2 font-mono text-[11px] text-ns-muted">
          {reasonText}
        </p>
      </div>
    </div>
  );
}

const CSS = `
.ns-rs-box{
  position: relative;
  display: inline-flex;
  min-width: 42px;
  height: 26px;
  align-items: center;
  justify-content: center;
  padding: 0 6px;
  border: 1px dashed var(--border);
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--foreground);
  transform-origin: center;
}
.ns-rs-box[data-signed]{
  border-style: solid;
  border-color: var(--foreground);
  transform: rotate(3deg);
}
.ns-rs-box[data-out-of-turn]{
  transform: rotate(2deg) skewX(8deg);
}
.ns-rs-box[data-arriving]{
  animation: ns-rs-chop-in ${CHOP_MS}ms cubic-bezier(0.16,1,0.3,1) both;
}
.ns-rs-box[data-arriving][data-out-of-turn]{
  animation-name: ns-rs-chop-in-skew;
}
@keyframes ns-rs-chop-in{
  from{ transform: scale(1.15) rotate(0deg); opacity: 0.45; }
  to{ transform: scale(1) rotate(3deg); opacity: 1; }
}
@keyframes ns-rs-chop-in-skew{
  from{ transform: scale(1.15) rotate(0deg) skewX(0deg); opacity: 0.45; }
  to{ transform: scale(1) rotate(2deg) skewX(8deg); opacity: 1; }
}

.ns-rs-now{
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--foreground);
  padding-bottom: 3px;
  background-image: repeating-linear-gradient(90deg, var(--foreground) 0 5px, transparent 5px 10px);
  background-position: 0 100%;
  background-repeat: repeat-x;
  background-size: 20px 1px;
  animation: ns-rs-crawl 900ms linear infinite;
}
@keyframes ns-rs-crawl{
  to{ background-position: -20px 100%; }
}

.ns-rs-btn{
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-mono);
  font-size: 11px;
  padding: 4px 10px;
  cursor: pointer;
  transition: background-color 150ms ease-out, border-color 150ms ease-out;
}
.ns-rs-btn:hover{
  border-color: var(--ns-accent);
  background: color-mix(in srgb, var(--ns-accent) 10%, var(--background));
}
.ns-rs-btn:active{
  background: color-mix(in srgb, var(--ns-accent) 18%, var(--background));
}
.ns-rs-btn:focus-visible{
  outline: 2px solid var(--ns-accent);
  outline-offset: 2px;
}
.ns-rs-btn[data-disabled="true"]{
  color: var(--ns-muted);
  cursor: not-allowed;
}
.ns-rs-btn[data-disabled="true"]:hover,
.ns-rs-btn[data-disabled="true"]:active{
  border-color: var(--border);
  background: var(--background);
}
.ns-rs-btn[data-disabled="true"]:focus-visible{
  outline: 2px solid var(--foreground);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce){
  .ns-rs-box[data-arriving]{ animation: none; }
  .ns-rs-now{ animation: none; }
  .ns-rs-btn{ transition: none; }
}
`;
Build spec

Build RouteSlip, a controlled-once approval ledger for a fixed chain of approvers. Props: `approvers` (an array of `{id, name, role}` in ROUTED order — index 0 signs first in sequence; this array's order is the row order and never changes, regardless of who actually signs when), `initialSignatures` (optional array of `{approverId, at}` seeding already-signed rows before mount, in whatever order they actually happened), `quorum` (signatures required before Publish unblocks, clamped to [1, approvers.length], default approvers.length — this can be LESS than the full roster, which is a deliberately different claim from "everyone must sign"), `currentApproverId` (the one approver whose row gets a real, clickable Sign button — every other row is read-only text regardless of its own signed state, because this is one person's view of a shared slip, not an admin panel that can sign on anyone's behalf), `docLabel`, and `onSign`/`onPublish` callbacks. Signing and publishing are each one-shot and irreversible: once `currentApproverId` has a timestamp there is no unsign path, and once Publish fires there is no unpublish path — the same terminal-state discipline as approval-inline-diff's decided guard. MECHANISM: the component renders a real `<table>` with a `<caption>` (sr-only) and four `<th scope="col">` columns — Approver, Role, Status, Time — one `<tr>` per approver in fixed routed order. The Approver cell prints the name followed by a small stamp box (1px dashed `--border`, ~42x26px). Before that approver signs the box is empty. The moment `currentApproverId` signs (Sign is a real `<button>`, present ONLY on that one row, reading "Sign as {name}"), the box fills with their initials in Geist Mono and plays a 260ms ease-out-expo press-in: `scale(1.15)` to `scale(1)` while settling into a `rotate(3deg)` tilt — a stamped, not typeset, mark. OUT OF TURN is the component's actual payload and is computed purely from final state, never from click order: approver i (by routed index) is out of turn if any earlier-routed approver j<i is still unsigned, or signed with a LATER timestamp than i's. Row 0 can never be out of turn — there is no one earlier to have skipped ahead of. A flagged chop's settle transform becomes `rotate(2deg) skewX(8deg)` instead of the plain 3deg tilt — noticeably irregular at a glance, still legible — and the row additionally renders a plain-text "out of turn" note beneath the name, so the distinction is carried by real text, not only by the skew. This is deliberately NOT a checkmark/avatar-overlay list: two approvers can both show "Signed" with identical green-free, color-free status text while only one of their stamps carries the skew, because a checkmark row has no way to encode routed-vs-actual order at all. GOVERNING SCALAR: signed count over quorum drives three derived surfaces — the header's "{n} of {total} signed" progress line (against the full roster, independent of quorum), the Status cell's "Now" label on the first unsigned row in routed order (independent of who `currentApproverId` is — "Now" can land on a row nobody present is allowed to sign), rendered with a crawling dashed underline (a `repeating-linear-gradient` sweeping via `background-position`, `--foreground`-only, never `--ns-accent`), and Publish's gating. Publish is a real `<button>` carrying `aria-disabled` (never the native `disabled` attribute, so it stays focusable and its reason stays reachable) plus `aria-describedby` pointing at a visible paragraph with the actual written reason, e.g. "Publish disabled — needs 1 more signature, waiting on Release Mgmt." once quorum is met the reason reads "Quorum met — ready to publish." and the button both loses `aria-disabled` and gains the enabled accent styling. A11Y: a polite `aria-live` region (sr-only, separate from the visible reason paragraph) announces on every genuine transition — never on the initial seeded mount, only on a sign or publish that happens in-session — phrased "{n} of {total} signed; waiting on {role}." (naming the blocking approver's ROLE, matching the brief's own example almost verbatim) or "...; all signed." once nobody remains, with "Publish unlocked." appended the moment quorum is crossed, and a final "Published." once Publish fires. Every signed row's timestamp is a real `<time datetime=...>` element. COLOR: strictly `--background` / `--foreground` / `--ns-muted` / `--border` / `--ns-accent`. `--ns-accent` appears nowhere but the Sign and Publish buttons' own hover/focus-visible/active states (border tint + a `color-mix(in srgb, var(--ns-accent) …%, var(--background))` wash, never a hex/rgb literal) — never on a chop, never as a persistent fill, and Publish's disabled variant overrides hover/active back to `--border`/`--background` and swaps its focus ring to `--foreground` so accent reads as an enabled-only signal exactly as specified. Under `prefers-reduced-motion`, the press-in keyframe and the "Now" underline's crawl animation are both stripped via a CSS media query (belt-and-suspenders alongside the JS `matchMedia` check that gates whether the press-in class is applied at all) — the final tilt/skew and the dashed underline still render at rest, fully legible on first paint, since a fixed tilt is not itself motion. Pure DOM and CSS, zero dependencies, no canvas or SVG.

Props

PropTypeDefaultDescription
approversRouteSlipApprover[]the approver chain in ROUTED order — fixed row order, index 0 is first in sequence. This order never changes regardless of actual signing order.
initialSignatures?RouteSlipSignature[]seed one or more approvers as already signed, in whatever order they actually signed (not necessarily routed order) — out-of-turn flags are derived from this at every render
quorum?numbersignatures required before Publish unblocks. Clamped to [1, approvers.length]. @default approvers.length
currentApproverId?stringthe approver whose row gets a real Sign button — every other row is read-only regardless of signed state
docLabel?string"Routing slip"label for the document being routed, shown as the card title and in the table's accessible caption
onSign?(approverId: string, at: number) => voidcalled once per successful sign, with the approver id and timestamp
onPublish?() => voidcalled once, when Publish is activated at or above quorum
className?stringextra classes merged onto the rendered root element