Danger Zone
A settings-page danger zone — one row per irreversible action with its consequences itemised, a type-the-resource-name gate, grace windows dated from the payload's own clock, and blocked actions that name their blocker instead of a dead button.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/account-danger-zone.jsonPrompt
Build a React + TypeScript + Tailwind "AccountDangerZone" block (zod,
lucide-react, and a type-to-confirm dialog — reuse a ConfirmDialog primitive
if you have one, otherwise build it on shadcn AlertDialog + Input).
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
resource: { kind, name, slug?, asOf? };
actions: { id, label, description,
consequences: { effect, text, amount? }[],
confirmPhrase, graceDays?, blockedReason?, blockedResolve?,
undoable, export? }[];
errorMessage? }
- effect is a closed set: "deleted" | "released" | "disconnected" |
"retained" | "owed". It drives the icon and the tint, so the reassuring
lines ("members and billing are untouched") cannot be styled as losses.
- amount is { currency: ISO-4217 code, minorUnits: integer } — 124000 = USD
$1,240.00, and the same integer is ¥124,000 in JPY. Never floats, never a
hard-coded /100 divisor: the exponent belongs to the currency and Intl
knows it.
- resource is present in ALL FOUR states — which workspace this page is
about comes from the route, not from the fetch, so the heading names it
while the list is still loading and the error branch can say whose
actions failed to load.
- Refinements the schema enforces: graceDays cannot sit on undoable:false
(an undo window on something with no undo is a lie), blockedResolve needs
a blockedReason, blockedReason cannot be an empty string, consequences
needs at least one entry, action ids are unique, every href passes an
ALLOW-list (absolute http(s) or a root-relative path — so "", "#",
"javascript:" and protocol-relative "//host" are all out; a deny-list of
known-dead strings is the wrong shape on a screen that deletes things),
and status "ready" with zero actions is a config error — that is the
empty branch.
- Props = z.infer of the schema plus onConfirm?(action), onRetry?(),
locale?, heading?, className, and the rest spread on the root <section>.
Omit onConfirm and the block renders as a read-only register: no
destructive triggers, only the export links stay.
Behavior
- Four first-class branches. loading: two row skeletons mirroring the real
row anatomy plus an sr-only role=status line. empty: "No destructive
actions" naming the resource. error: the host's errorMessage or a
generic sentence, and it must state that nothing was changed — plus a
retry only when onRetry exists. ready: one <li> per action.
- Consequences are on the page BEFORE the button, and repeated inside the
confirm dialog. Nobody should type a workspace name and discover
afterwards that it took nine seats with it.
- Type-to-confirm: the dialog's confirm button unlocks only on an exact
match with the action's confirmPhrase (convention: the resource name —
it is on screen, unambiguous, and defeats muscle memory). Every open
starts with a cleared input.
- Blocked actions never open the dialog. Their trigger is aria-disabled,
NOT disabled: a disabled button leaves the tab order, which hides the
reason from exactly the people who cannot see the panel beside it. It
stays focusable, points aria-describedby at the blocker panel, and
carries no handler. The panel states the blocker and, when the payload
supplies one, a real link that clears it.
- The recovery sentence under each action is DERIVED, never authored:
undoable:false → "Permanent — there is no undo and no recovery window";
undoable + graceDays + asOf → "Recoverable for 30 days: confirm on Aug 18,
2026 and it stays restorable until Sep 17, 2026"; graceDays without asOf →
the relative phrasing only; undoable with no graceDays → "Reversible, but
not on a timer". Dates are computed from asOf in fixed 24-hour days and
formatted in UTC — reading the browser clock would render one string on
the server and another in the client on the one screen that destroys
things.
- The header count line is derived too: "4 actions · 2 permanent ·
2 blocked" is computed from the array, so it cannot drift from the rows.
- The section chrome says "destructive", never "irreversible" — the same
list holds a Permanent badge and a "30-day undo window" badge, so a
headline claiming everything here is irreversible would be false about
the rows underneath it. Undoability is carried per row, by the badge and
the derived recovery line, and nowhere else.
- "Export your data first" renders wherever the action carries an export
link, as a real <a> ahead of the destructive trigger — and it is NOT
gated on onConfirm: someone who cannot run the action is exactly who
still wants a copy of the data out.
- The open dialog re-reads its action from the current payload every
render and renders from that, falling back to the action it was opened
with only once the row is gone — so the consequences being read are the
ones the confirm will run, and the text still does not flicker during
the closing animation. If a refetch dropped that action or newly blocked
it, the dialog closes itself, and that close is latched into state: a
later refetch that restores or unblocks the row must not spring a
destructive confirm box open again without a click.
- Running the action is the host's job — the block calls onConfirm and lets
the host remove the row. Returning a promise drives the dialog's pending
state; rejecting keeps it open with the reason inline.
Rendering & styling
- Semantic tokens only: border-destructive/40 on the section, a
bg-destructive/5 header, bg-card body, bg-muted/40 consequence panels,
text-muted-foreground for supporting copy. destructive tint is spent only
on the "deleted" and "owed" lines, the Permanent badge and the blocker —
a list where every line is red has no emphasis left.
- Rows are a vertical stack that stays legible at ~420px: badges wrap under
the title, the consequence list never truncates, buttons wrap.
- Accessibility: section labelled by its heading, role="list" on both lists
(Tailwind's reset removes list semantics in Safari + VoiceOver), icons
aria-hidden with an sr-only effect label ("Erased:", "Still owed:") in
front of each consequence, focus-visible rings on every control.
- Skeletons carry motion-reduce:animate-none; there are no timers,
observers or listeners to tear down — the dialog state is derived during
render, not in an effect.
Customization levers
- Row density: drop the description line or the per-action badges for a
compact list; the consequence panel is the one part that should never be
collapsed behind a disclosure.
- Effect vocabulary: extend the effect enum (e.g. "anonymised",
"downgraded") and add an icon, an sr-only label and one tone entry. The
tone entry is a record of all three tinted surfaces at once — icon, text
and amount — so it really is the only place that decides what looks like
a loss; keep it that way and never re-derive a colour from the effect
inline.
- Gate strength: swap confirmPhrase from the resource name to a slug, an
email or a typed "DELETE"; or require a re-auth step by making onConfirm
return a promise that resolves after your password check.
- Blocked treatment: render blocked rows collapsed, or hide them entirely
behind a "show blocked actions" toggle if your product prefers a shorter
zone — keep the reason visible wherever the row is.
- Section chrome: heading is a prop, and the destructive border/tint can be
neutralised to border + bg-card if your settings page already signals the
zone some other way.
- Money: amounts render through Intl by currency code; pass a locale to
move the symbol, the grouping and the minus sign placement.Concepts
- Consequence disclosure before the gate — the itemised outcomes sit above the button and are repeated inside the dialog, so the phrase is typed with the seat count, the disconnected integrations and the unpaid invoices already on screen. A confirm box whose only content is "are you sure?" trains people to type the name reflexively.
- Type-to-confirm on the exact resource name — the gate is not friction for its own sake; matching a name that is visible on the page is what stops the wrong workspace being deleted from a stale tab.
- A blocker is information, not a dead control —
blockedReasonplus an optional resolve link replaces the disabled button nobody can interrogate.aria-disabledkeeps it in the tab order precisely so the reason is announced to the people who cannot see it. - Undoable is not the same as recoverable —
undoablesays whether anything can reverse it,graceDayssays whether a clock is doing the reversing. Ownership transfer is undoable with no window; a revoked session is permanent even though nothing was deleted. - Derived dates, one clock — the grace deadline is computed from the payload's
asOf, in fixed 24-hour days and printed in UTC, so a server render and a browser render produce the same sentence and the demo is reproducible. - The host owns the outcome — the block gates and explains; it never mutates.
onConfirmhands the action back and the row disappears because the data changed, which is what keeps the four states honest.
Feature Hover Preview
A feature list where hovering or focusing a row cross-fades the preview panel beside it, with a measured accent rail on the active row and media inline on touch.
Webhooks Manager
A four-state webhook endpoint manager — event multi-select, reveal-and-rotate signing secrets with the overlap window stated, test sends that explain when they are blocked, and per-endpoint deliveries with retry.