Diff Confirm
A confirmation dialog that shows the change list first — grouped create/update/delete counts, expandable before → after rows, type-to-confirm past a destructive threshold, and a failure state that keeps the plan on screen.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/diff-confirm.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DiffConfirm" component (React 19 +
react-dom + lucide-react only; no Radix, no dialog library). It is a modal that
shows WHAT is about to change before asking for confirmation.
Contract
- export type DiffChangeKind = "create" | "update" | "delete"
- export interface DiffChange { id: string; label: string; kind: DiffChangeKind;
before?: string; after?: string; detail?: string }
- id is the identity used for React keys and for expand state
- label is the thing being touched (resource name, setting key, file path)
- before/after are the values shown when the row is expanded
- detail is one muted line under the label, always visible, never truncated
- export const DiffConfirm = React.forwardRef<HTMLDivElement, DiffConfirmProps>,
DiffConfirmProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">:
- open: boolean and onOpenChange: (open: boolean) => void — CONTROLLED ONLY.
There is no trigger prop and no internal open state: this dialog is opened by
a flow ("plan finished, here is the diff"), not by a button next to it.
- changes: DiffChange[]
- title? = "Review changes", description?: ReactNode
- confirmLabel? = "Apply changes", cancelLabel? = "Cancel",
pendingLabel? = "Applying…", retryLabel? = "Retry"
- onConfirm: () => Promise<void> — resolve = success, reject = failure
- requireTyping?: string — type-to-confirm phrase
- warnThreshold? = 3 — deletions above this auto-require typing
- className merged onto the panel via cn(); remaining props spread onto it
- The forwarded ref points at the panel, which only exists while open.
Behavior — the change list
- Normalize once, in a useMemo over `changes`: drop entries whose id was already
seen (duplicate React keys make deleting one row unmount a different one), and
bucket by kind. A kind outside the union (a backend that grew a new verb) is
filed under "update" rather than dropped — rendering one row wrongly is much
safer than silently not showing a change that is about to happen.
- Groups render in create -> update -> delete order, each as a
<section role="group" aria-labelledby> containing a heading and a <ul>. Keep
<li> as direct children of <ul>: a role-less div in between breaks the list
ownership chain and screen readers announce an empty list.
- Header shows the counts as "3 to create · 1 to update · 2 to delete" (only
non-empty kinds; the separator is aria-hidden) and is the dialog's
aria-describedby target, so opening announces the blast radius.
- Every row carries an icon AND a text badge ("Create" / "Update" / "Delete"),
and the delete group additionally gets its own border, tinted background and an
"Irreversible" line. Colour is never the only signal — the site's default
palette is monochrome and a red-only cue disappears entirely there.
- A row is expandable when it has a value to show for its kind: before for
update/delete, after for update/create. Expanding renders a two-column grid
(auto-fit minmax so it stacks on narrow panels) with "Before" and "→ After"
blocks. Collapsed rows are UNMOUNTED, not height-zeroed: a
grid-rows-[0fr] collapse still lets Tab reach whatever is inside it.
- Long identifiers (ARNs, paths) use break-words, not break-all. break-all
collapses the column's min-content width to a single character, so the label
column volunteers to be squeezed even when there is room to spare.
- Empty list: the summary reads "Nothing to apply", the body explains itself, and
confirm is blocked. It must never look like it applied something.
Behavior — the confirmation gate
- requiredPhrase = requireTyping (trimmed; an empty/whitespace string counts as
not passed, otherwise the gate would be satisfied by typing nothing) OR, when
the number of deletions exceeds warnThreshold, the literal word "delete".
Comparison is trimmed but case-sensitive.
- Clamp warnThreshold: NaN falls back to the default (NaN comparisons are always
false, which would silently disable the safety net), negatives clamp to 0
(any deletion requires typing), Infinity is left alone as the legitimate way to
say "never auto-require".
- Confirm is blocked while pending, while the list is empty, and while the phrase
is unsatisfied. Blocked means aria-disabled + an early return in the handler,
NEVER the native disabled attribute: a button that disables itself the moment
it is pressed gets blurred by the browser, focus lands on <body>, and the user
can no longer reach the Retry that appears seconds later. aria-disabled also
keeps the button focusable and clickable, so a blocked click can do something
useful — here it moves focus into the phrase field instead of dying silently.
- Submitting: setPending(true), then
`await new Promise<void>(resolve => { resolve(onConfirm()) })`. Use the
constructor, not Promise.resolve(onConfirm()): a synchronous throw inside
onConfirm escapes before Promise.resolve ever sees it and the button stays
pending forever, while the executor turns it into a rejection.
- After the await, re-check a mountedRef before touching state — the surrounding
screen is often gone by the time a slow apply resolves. Set that ref to true in
the effect BODY (not only false in cleanup), or StrictMode's
mount → cleanup → mount leaves it false for a live instance and pending never
clears.
- Success: request close via onOpenChange(false). Failure: stay open, keep the
ENTIRE list and the typed phrase, render the message in a role="alert" block,
and relabel confirm to retryLabel. Never report success you did not observe.
- While pending the dialog refuses every dismissal — Escape, backdrop, Cancel.
The work is already in flight; closing would tell the user nothing happened.
Behavior — overlay mechanics
- Render through createPortal into document.body: a scrim (fixed inset-0, flex
centering, overflow-y-auto) plus the panel. Portalling is what makes the dialog
survive being mounted inside an overflow-hidden card, preview stage or
scrollable region — an in-flow modal there is drawn inside the clip.
- Gate the portal on a client flag from
useSyncExternalStore(subscribe, () => true, () => false) so SSR and the
hydrating frame render nothing instead of touching document.
- Backdrop dismissal listens to pointerdown and compares
event.target === event.currentTarget, so selecting text inside the panel and
releasing over the scrim does not count as "clicked outside".
- Escape and the Tab trap live on the panel's own onKeyDown with
stopPropagation, not on window: a window listener cannot tell which layer is
on top, and one Escape would close this dialog and the drawer underneath it.
aria-modal="true" claims the page behind is unreachable, so Tab must actually
cycle between the first and last visible focusable inside the panel.
- Opening focuses the phrase input when there is one, otherwise Cancel — never
the destructive confirm. Closing returns focus to whatever had it, guarded by
isConnected: the action frequently unmounts the trigger, and focusing a
detached node silently drops focus onto <body>.
- Body scroll lock while open, with its reentrancy count and pre-lock snapshot
stored as data attributes on document.body (zyScrollLocks /
zyScrollLockOverflow / zyScrollLockPadding), never in module-level variables:
every component ships as its own copy, so two overlays with private counters
cannot see each other and the second release writes back the "hidden" it
recorded as the original — the page stays locked with nothing on screen to
blame. Scrollbar compensation is MEASURED (read clientWidth, set
overflow: hidden, read again, add the positive delta to paddingRight), because
the innerWidth - clientWidth shortcut over-pads by ~15px on pages with
scrollbar-gutter: stable and shifts the content left.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
shadow-2xl for the panel, bg-background/80 + backdrop-blur-sm for the scrim,
bg-muted for value blocks, text-destructive + border-destructive/50 +
bg-destructive/5 for the delete group, ring-ring for focus rings.
- The destructive confirm is `bg-destructive text-background` — there is no
--destructive-foreground token in this system, and hardcoding white gives ~2.3:1
on the lighter red of the dark theme. text-background flips with the theme
(measured 5.6:1 light, 7.3:1 dark).
- Layout is a three-part flex column with max-h-[85vh]: header (title +
counts), `min-h-0 flex-1 overflow-y-auto` list, footer (phrase field, error,
buttons). Only the middle scrolls, so a 30-row plan can never push the confirm
button out of view — the single most important layout constraint here.
- Enter animation: scrim fade + panel fade/slide (150ms ease-out) shipped as one
React 19 hoisted <style href precedence> tag, so N instances emit one rule set.
motion-reduce:[animation:none] removes the motion; nothing depends on it.
- A permanently mounted role="status" span carries the pending / failure
sentence. Inserting a live region only when it has content is the classic way
to lose the first announcement.
- "use client": state, effects, portal, focus and DOM measurement.
Customization levers
- Kinds: KIND_ORDER + KIND_META are the whole vocabulary. Add "replace" or
"no-op" by extending the union, the meta record (icon, badge word, summary
wording) and the order array; nothing else branches on kind except the
destructive styling test.
- Risk policy: warnThreshold (default 3) decides when typing becomes mandatory;
AUTO_PHRASE ("delete") is what the user must copy — swap it for the resource
name by passing requireTyping instead. Pass warnThreshold={Infinity} for a
purely informational preview.
- Density: the panel is max-w-2xl / max-h-[85vh]; className overrides both.
Drop `detail` from the data for a compact list, or render before/after as a
single line instead of two blocks.
- Expansion: default everything expanded by seeding the expanded set from
`changes`, or make it non-expandable by not passing before/after at all — the
row silently renders as a static div instead of a button.
- Semantics: the same shell works for "apply / discard / dry-run" by swapping
confirmLabel and adding a third footer button; keep Cancel first in DOM order
so it is the first thing Shift+Tab reaches.Concepts
- Plan before apply — the dialog's job is not to ask "are you sure", it is to make the blast radius legible first: a count line you can read in one second, then a per-row list you can audit. The confirmation is the last step, not the whole interaction.
- Grouped counts as the headline — the summary is the dialog's
aria-describedby, so both the eye and the screen reader get "3 to create · 1 to update · 2 to delete" before anything else. It sits in the fixed header, above the scroll area, so it stays true no matter how far the list is scrolled. - Redundant coding for the destructive group — deletions are marked by an icon, a "Delete" word badge, a group border, a tinted panel and the word "Irreversible". Colour alone fails on a monochrome palette and for colour-vision deficiency, and this is exactly the row you cannot afford to have someone miss.
- Expandable before → after — the row is the claim ("api-gateway will change"), the expansion is the evidence (
cpu = 512→cpu = 1024). Collapsed expansions are unmounted rather than height-collapsed, because a zero-height container is still reachable by Tab — an invisible keyboard trap. - Type-to-confirm as a threshold, not a mood — typing is demanded either explicitly (
requireTyping="acme-prod") or automatically once the number of deletions passeswarnThreshold. Tying the friction to the measured blast radius keeps the small cases fast and makes the big ones deliberate. aria-disabled, notdisabled— a button that becomes disabled the instant it is pressed is blurred by the browser, dropping focus on<body>and stranding the keyboard user before the Retry ever appears.aria-disabledplus an early return in the handler keeps the button focusable, keeps it clickable, and lets a blocked press do something useful — moving focus into the field that is blocking it.- Pending is uncloseable, failure is unclosed — while the request is in flight every dismissal path is refused, because closing would imply nothing happened. When it rejects, the dialog stays exactly where it was with the full list and the typed phrase intact, shows the real message in a
role="alert", and relabels the primary button to Retry.
Status Timeline
A read-only progress thread for an async run — icon plus word per stage, a live clock on the running one, expandable failures and a settled-run summary.
Rate Limit Notice
A 429 panel with a self-correcting cooldown clock — absolute-instant countdown, auto-unlock with milestone-only announcements, remaining quota with two urgency levels, consecutive-backoff labelling and an upgrade CTA.