ConfirmPopover
Preview
Basic
Loading…
Preview
Code
ts
import ConfirmPopover from '@/components/overlay/ConfirmPopover';src/components/overlay/ConfirmPopover.tsx
AI prompt
text
Build an inline confirm popover component, anchored to its trigger, in React + TypeScript + Tailwind CSS.
## Look
- Trigger: root `relative inline-flex` around a span `inline-flex w-full items-center justify-center cursor-pointer` holding `children`. Use flex, not inline-block, so an icon-button trigger lines up with its flex-centred neighbours instead of sitting on a text baseline.
- Panel: portalled to <body>, `fixed z-[200]`, 256px wide (`w-64`), `p-3 text-left`, on the frosted card surface.
- Content row `flex items-start gap-2`: a 16px lucide `TriangleAlert` (`shrink-0 mt-0.5`; rose-500 when destructive, indigo-500 otherwise); then the title (text-xs font-semibold slate-800 / dark slate-100) and the description (`mt-0.5` 11px slate-500 / dark slate-400).
- Buttons `mt-3 flex justify-end gap-2`: a ghost Cancel and a primary Confirm. Destructive Confirm is `bg-rose-600 hover:bg-rose-700`.
## Behaviour
- A click on the trigger toggles the panel.
- Placement: right-aligned to the trigger (`left = rect.right - 256`), clamped to `[8, vw - 256 - 8]`. Below the trigger with a 4px gap, unless that would pass `innerHeight - 8` (a Delete at the foot of a form); then it opens above (`rect.top - 4 - height`, min 8). Estimate the height at 120px on the first placement, then re-place on the next animation frame with the measured height, so the flip decision is real. Re-place on resize and on scroll (capture phase: triggers live inside table scrollers).
- Outside click: the panel is portalled, so both the trigger root AND the panel count as inside. Otherwise the click on Confirm would close the panel before Confirm runs.
- Confirm has `autoFocus`; it closes the panel, then calls `onConfirm`. Cancel only closes.
- Set `data-overlay="popover"` on the panel. A dialog or drawer containing the trigger then treats clicks in it as inside and leaves Escape to it.
## API
- `onConfirm(): void`, `children` (the trigger)
- `title?` = "Are you sure?", `description?` = "This cannot be undone.", `confirmText?` = "Confirm", `cancelText?` = "Cancel"
- `variant?: 'destructive' | 'default'` = `'destructive'`. `default` is for a confirm that is significant rather than irreversible (publish, release); a rose button would miscolour it.
## Accessibility
- Panel `role="dialog" aria-modal="false" aria-label={title}`.
## Demo
A small ghost "Remove" trigger in rose text ("Remove this member?" / "They lose access immediately." / Remove). Next to it, a default-variant "Publish" trigger ("Publish these changes?" / "Everyone in the workspace will see them." / Publish / "Not yet"), and a counter reading "confirmed N×".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleAlert } from 'lucide-react';
import { useDismiss } from '@/lib/use-dismiss';
/**
* A confirm step that opens next to its trigger instead of taking the whole
* screen. Used for destructive row actions, where a full modal is heavier than
* the decision warrants.
*
* PORTALLED and `position: fixed`, not absolute. Its triggers sit inside table
* shells that are `overflow-hidden` (to keep a panel's rounded corners) or
* `overflow-x-auto` (to scroll a wide table) — and CSS computes the other axis
* to `auto` once one is not `visible`. An absolutely-positioned panel therefore
* OPENED BUT WAS CLIPPED, which reads exactly like a broken button.
*
* Two refs, not one: with the panel in a portal it is no longer a DOM
* descendant of the trigger, so an outside-click check against the trigger
* alone would close the panel on the very click that hit Confirm.
*
* MERGED from two copies. bonus-adjustment's is the base — it is the one that
* portals, so it cannot be clipped by the `overflow-hidden` table shell its
* triggers live in. `variant` and `cancelText` came across from
* marketing-stats: this confirm is not always destructive (publishing, or
* releasing a batch, is a confirm too), and a rose Confirm button on a
* non-destructive action miscolours the decision.
*/
const PANEL_WIDTH = 256; // w-64
const EDGE = 8;
const GAP = 4;
export default function ConfirmPopover({
onConfirm,
children,
title = 'Are you sure?',
description = 'This cannot be undone.',
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'destructive',
}: {
onConfirm: () => void;
children: React.ReactNode;
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
/**
* Tones the warning icon and the Confirm button. Defaults to `destructive`,
* which is what most row actions behind this are — `default` is for a confirm
* that is merely significant rather than irreversible.
*/
variant?: 'destructive' | 'default';
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const place = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Right-aligned to the trigger, then clamped so a button near either edge
// still shows the whole panel.
const left = Math.min(
Math.max(r.right - PANEL_WIDTH, EDGE),
Math.max(EDGE, window.innerWidth - PANEL_WIDTH - EDGE),
);
// Below the trigger, unless that runs off the bottom of the window — a
// Delete at the foot of a form panel is exactly there — in which case it
// opens above. The height is estimated before the panel has mounted and
// measured on the re-place right after, so the flip decision is real.
const height = panelRef.current?.offsetHeight ?? 120;
const below = r.bottom + GAP;
const top = below + height <= window.innerHeight - EDGE ? below : Math.max(EDGE, r.top - GAP - height);
setPos({ top, left });
}, []);
useLayoutEffect(() => {
if (!open) return;
place();
// Once more after the panel has mounted, with its measured height.
const frame = requestAnimationFrame(place);
// `true` for capture: a scroll inside the table's own scroll box does not
// bubble, and that is exactly the container these triggers live in.
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [open, place]);
// The panel is portalled, so it is not a descendant of the trigger: both roots count as inside.
useDismiss([triggerRef, panelRef], open, () => setOpen(false));
// `inline-flex`, not `inline-block`, on BOTH the root and the trigger.
//
// Inline-block put the child button on a text baseline, so a trash icon in a
// row of icon buttons sat a couple of pixels lower than its neighbours — the
// others being flex-centred. Flex on both makes the trigger fill the root and
// centre whatever it wraps, so a caller's icon lines up with any sibling.
return (
<div className="relative inline-flex" ref={triggerRef}>
<span
onClick={() => setOpen((o) => !o)}
className="inline-flex w-full items-center justify-center cursor-pointer"
>
{children}
</span>
{open &&
pos &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={panelRef}
role="dialog"
aria-modal="false"
aria-label={title}
// An AnchoredPanel treats a click in any `[data-overlay]` as inside,
// so confirming from within one does not close the form behind it.
data-overlay="popover"
style={{ top: pos.top, left: pos.left, width: PANEL_WIDTH }}
className="fixed z-[200] panel p-3 text-left"
>
<div className="flex items-start gap-2">
<TriangleAlert
className={
variant === 'destructive'
? 'w-4 h-4 text-rose-500 shrink-0 mt-0.5'
: 'w-4 h-4 text-indigo-500 shrink-0 mt-0.5'
}
/>
<div>
<h3 className="text-xs font-semibold text-slate-800 dark:text-slate-100">{title}</h3>
<p className="mt-0.5 text-[11px] text-slate-500 dark:text-slate-400">
{description}
</p>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-ghost">
{cancelText}
</button>
<button
type="button"
autoFocus
onClick={() => {
setOpen(false);
onConfirm();
}}
className={
variant === 'destructive'
? 'btn-primary bg-rose-600 hover:bg-rose-700'
: 'btn-primary'
}
>
{confirmText}
</button>
</div>
</div>,
document.body,
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
onConfirm* | () => void | — | |
children* | React.ReactNode | — | |
title | string | 'Are you sure?' | |
description | string | 'This cannot be undone.' | |
confirmText | string | 'Confirm' | |
cancelText | string | 'Cancel' | |
variant | 'destructive' | 'default' | 'destructive' | Tones the warning icon and the Confirm button. Defaults to `destructive`, which is what most row actions behind this are — `default` is for a confirm that is merely significant rather than irreversible. |