Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUpRight,
  CircleMinus,
  Download,
  Lock,
  Receipt,
  ShieldAlert,
  ShieldCheck,
  Trash2,
  TriangleAlert,
  Unplug,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/account-danger-zone.json

Prompt

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 controlblockedReason plus an optional resolve link replaces the disabled button nobody can interrogate. aria-disabled keeps it in the tab order precisely so the reason is announced to the people who cannot see it.
  • Undoable is not the same as recoverableundoable says whether anything can reverse it, graceDays says 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. onConfirm hands the action back and the row disappears because the data changed, which is what keeps the four states honest.

On This Page