Legal Consent
A versioned terms and privacy acceptance panel: what changed since the version on file, full text that must actually be reached before its box unlocks, separable consents, and the record it hands back.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/legal-consent.jsonPrompt
Build a React + TypeScript + Tailwind "LegalConsent" block (lucide-react icons,
zod contract, shadcn Button + Badge).
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
documents: { id, label, version, effectiveAt (ISO date), url, required,
summary?, body?: string[], consentLabel? }[];
changes?: { documentId, kind: "added" | "removed" | "changed", summary }[];
previouslyAccepted?: { documentId, version, acceptedAt (ISO) }[] }.
- Props = z.infer of that schema plus onAccept?(record), onRetry?, heading?,
description?, className, and the remaining HTML attributes spread on the root.
- onAccept receives the same shape as previouslyAccepted, one row per document:
the consents just given, plus the rows already on file carrying their ORIGINAL
timestamps — re-stamping an old acceptance falsifies it. It is the current
consent state, NOT a ledger: a document re-accepted at a new version emits only
the new row, so say in the JSDoc that superseded rows are kept server-side and
never overwritten with this array, and that only the latest row per document
comes back as previouslyAccepted. Say too that the timestamp comes from the
visitor's clock and the server should stamp the authoritative one.
- previouslyAccepted holds at most one row per document — the latest. If several
rows for the same id do arrive (a slice of an append-only ledger), the newest
acceptedAt is the version on file, never whichever row sits first in the array,
or the block re-asks for a version already accepted and prints the delta
against the wrong baseline.
Behavior
- Four first-class branches. loading = skeletons in the real card anatomy plus
an sr-only status line; empty = "nothing to accept" panel (a ready payload
with zero documents lands here too); error = state that nothing was recorded
and render no consent step at all, with "Try again" only when onRetry exists;
ready = the panel below. The header description belongs only to the branches
that have documents (loading and ready): the empty and error panels carry their
own copy and must not sit under a line telling the visitor to review documents
and record consents that do not exist.
- Per document, compare version with the previouslyAccepted row: same version =
settled (no checkbox, just "on file since <stamp>"); different = show
"What changed since v<old>" with the supplied clause deltas, counted by kind
("1 added · 1 removed · 1 changed"). No deltas supplied = say that no summary
was published — never synthesise one from the body text.
- Reading gate: a required document that ships body text renders it in a
scrollable, focusable region (role=region + aria-label, tabIndex 0). Its
checkbox stays disabled until the pane is scrolled to its end, and an
adjacent line + a "Skip to the end" button say exactly how to clear it.
Measure scroll from the DOM, throttle with one rAF, re-measure on resize of
both the pane AND its content, and treat text too short to scroll as read —
otherwise the box is dead forever. Guard that shortcut on the pane having a
real box (clientHeight > 0): a pane mounted hidden or collapsed measures 0 and
must leave progress and the gate untouched until the observer reports a size,
or the gate clears itself before a line has been painted. Reaching the end is
one-way.
- The gate is a nudge, not evidence: "Skip to the end" clears it in one press and
nothing in the record tells a skipped document from a read one. Word the helper
line as the way past the scroll for people who cannot scroll, not as a shortcut,
and keep the evidentiary claim on the acceptance row — document, version, time.
- Separable consents: one checkbox per document, each producing its own record
row. Optional documents (marketing) are never gated and never block accept.
- The primary button is never a dead disabled button: when required consents
are outstanding it is aria-disabled but still pressable, and pressing it
scrolls to the first blocker, focuses the pane or box in the way, and turns
the role=status line destructive. That line always names the next thing to
do, and once cleared says how many documents the record will contain.
- After accepting, the panel becomes the receipt: each accepted document with
version and UTC stamp, a "not accepted" list for declined optional consents,
and a note that this screen stores nothing itself. Scope any provenance
sentence to the rows it covers — only the consents given just now come from
this device; carried-over rows keep the time they were first recorded.
- Dates use a fixed locale and UTC via Intl.DateTimeFormat, so server and
client agree; an unparseable date prints raw instead of "Invalid Date".
Rendering & styling
- Semantic tokens only: bg-card rows, bg-muted/30 reading pane, border-dashed
for the delta and record blocks, text-destructive for the blocked state and
removed clauses, text-primary for progress fill, satisfied rows and checks.
- accent-primary on a real <input type=checkbox> (no div pretending to be a
control); focus-visible rings on the pane, boxes, links and buttons. The pane's
ring must be inset (ring-inset), because it sits inside an overflow-hidden
wrapper that clips an outward ring away completely — leaving the one control
the gate depends on with no keyboard focus indicator at all.
- prefers-reduced-motion: smooth scrolls fall back to instant, skeleton pulses
and the progress transition stop. Every listener, ResizeObserver and rAF is
cancelled on unmount.
- Legible from ~420px: header chips wrap, the pane caps at max-h-48, the root
is mx-auto max-w-2xl and merges className through cn().
Customization levers
- Gate strength: swap the scroll gate for a dwell requirement, or drop it by
not passing body — the checkbox then enables immediately and the link stays.
- Pane height: max-h-48 is the one number to tune for density; the progress
bar and Skip-to-the-end button live in the pane footer, remove either.
- Document anatomy: summary, changes block, pane and record strip are four
independent blocks — delete any of them without touching the gate logic.
- Consent wording: consentLabel overrides the generated sentence per document,
which is where counsel's exact phrasing goes.
- Record surface: the receipt and the "already on file" strip are the two
places the record renders; point them at a server-side ledger instead.
- Layout: max-w-2xl via className, or render the whole block inside a Dialog
for a re-consent interstitial.Concepts
- Version-scoped consent — a document is outstanding only when the offered version differs from the one on file, so an unchanged document is shown as settled instead of being asked for again.
- Scroll-to-end gate — the box unlocks on a measured DOM fact (the pane reached its bottom), not a timer; text too short to scroll counts as read, because gating on an impossible scroll is how a control ends up dead forever — but only once the pane has a real box, since a pane mounted hidden measures zero and must not clear its own gate. It is a nudge that puts the text in front of the visitor, not evidence it was read: "Skip to the end" clears it in one press, and the record cannot tell a skipped document from a read one.
- Clause delta, not a diff — "what changed" is the publisher's own added/removed/changed lines. When none are supplied the block says so rather than generating a summary it cannot stand behind.
- Separable consents — terms, privacy and marketing are independent checkboxes producing independent record rows; the optional one never blocks the primary action.
- Blocked action explains itself — the accept button stays pressable while blocked, and the press scrolls to the first unmet document, focuses the thing in the way and names it in a
role="status"line. - Acceptance record loop — what
onAcceptemits is exactly whatpreviouslyAcceptedtakes back: the current consent state, one row per document, with already-accepted rows keeping their original timestamps so nothing is quietly re-stamped. It is not the ledger — a document re-accepted at a new version emits only the new row, so the superseded rows stay in your store rather than being overwritten by this array.
Trust Center
The public security page a B2B buyer asks for — audit dates measured against each report's own refresh cycle, a filterable subprocessor table with DPA links, plain-language practices, uptime, and when a human last reviewed the page.
Seat Management
A member roster crossed with the seat ledger — who holds a billable, free, invited or deactivated seat, the cost of adding or removing one previewed before it is committed, an overage warning with both of its exits, and a bulk deactivation that says what it frees before it says what it saves.