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.

Usage

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

import { RouteSlip, type RouteSlipApprover } from "./component";

// Same three-person chain reused by both cards, so the only variable between
// them is who has actually signed and in what order — the whole point of the
// component.
const APPROVERS: RouteSlipApprover[] = [
  { id: "priya", name: "Priya Shah", role: "Engineering" },
  { id: "legal", name: "Legal Review", role: "Legal" },
  { id: "marcus", name: "Marcus Webb", role: "Release Mgmt" },
];

export default function RoutingSlipDemo() {
  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 / routing-slip — routed order vs. actual order
      </p>

      <div className="flex w-full max-w-xl flex-col gap-8">
        <div>
          <p className="mb-2 text-sm font-medium text-foreground">
            Live — you are Marcus Webb (Release Mgmt)
          </p>
          {/* Nobody has signed yet. Marcus is last in the routed chain but
              the only row with a real Sign button belongs to whoever is
              `currentApproverId` — signing here, before Priya or Legal,
              is the out-of-turn case landing live rather than pre-seeded. */}
          <RouteSlip
            className="ns-rs-live"
            approvers={APPROVERS}
            quorum={2}
            currentApproverId="marcus"
            docLabel="PR #482 — Search relevance rewrite"
          />
        </div>

        <div>
          <p className="mb-2 text-sm font-medium text-foreground">
            At rest — fully signed, mixed order, quorum met
          </p>
          {/* No currentApproverId, so no row here ever renders a Sign
              button — this card is read-only, showing the resolved state:
              Legal and Marcus both signed before Priya (who is routed
              first and therefore can never read as out of turn herself),
              so both their chops carry the out-of-turn skew and note while
              hers doesn't, even though she signed last. */}
          <RouteSlip
            approvers={APPROVERS}
            quorum={3}
            docLabel="RFC-114 — Rollout policy"
            initialSignatures={[
              { approverId: "legal", at: new Date(2026, 7, 17, 9, 12) },
              { approverId: "marcus", at: new Date(2026, 7, 17, 9, 40) },
              { approverId: "priya", at: new Date(2026, 7, 17, 10, 5) },
            ]}
          />
        </div>
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Rows stay in routed order no matter who actually signs first. An
        early signature is permitted, not blocked — it lands with an 8deg
        skew and an &ldquo;out of turn&rdquo; note instead. Publish reads
        signed count against quorum, which can unblock before every name is
        stamped.
      </p>
    </div>
  );
}

Component

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

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

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.

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?number—signatures required before Publish unblocks. Clamped to [1, approvers.length]. @default approvers.length
currentApproverId?string—the 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) => void—called once per successful sign, with the approver id and timestamp
onPublish?() => void—called once, when Publish is activated at or above quorum
className?string—extra classes merged onto the rendered root element