Components
Modal & dialog
The centered overlay dialog. It's built on React Aria, so the behavior that's easy to get wrong — focus trap, scroll lock, Escape-to-close, return-focus-to-trigger, the dialog ARIA — comes from the library, never hand-rolled. We paint one solid, friendly panel: the page dims behind a simple scrim, and the panel sits on top as a flat card with a soft, warm shadow. It pops in with a quick little scale, then gets out of your way.
Controlled, like everything else here — hand it isOpen and onOpenChange; the trigger is any Button you like. For the most common job — confirming an action before it runs — grab ConfirmDialog instead of wiring up a footer yourself.
Modal
A title, a body, and a footer of actions. The header carries a bold slab-serif title and a close (×); Escape, the scrim, and × all route through onOpenChange. Try it below.
ConfirmDialog
The yes/no convenience: an alertdialog (the user must choose), small, with a Cancel / Confirm footer. The critical tone gives the confirm button its solid danger fill — the unmistakable read for a delete or a discard. This is what the detail-view danger zone fires.
idle.)Sizes & dismissal
Three width tiers — sm (confirmations), md (the default), lg (forms with room). isDismissable is on by default; turn it off for a step the user must not skip past (an unsaved-changes guard). showClose hides the × when the only ways out should be the footer actions.
Materiality
Everything resolves to tokens; the modal owns layout, the surface owns the solid color.
- scrim
- A gentle dim —
color-mix(--bg-page 40%)over the page. No backdrop blur here — blur belongs to the proposed system's glass. The official scrim is a plain dim, and the panel's shadow does the focus work. - panel
- The
card--sheetsurface — under the official brand that's a flat, opaque card: solid fill, no frost, no silver frame, just the warmest, deepest shadow in the system. (The 12px frost is the proposed system's read.) - title
- Flat, not engraved. The
--engraving-shadowtoken is the proposed system's carving; here the title is simply set in the display slab — solid brown, full confidence, zero shadow tricks. - motion
- The panel pops in (opacity + a quick, small scale) over
--dur-base; reduced-motion turns the animation off. There's no blur to drop — the official brand never had one.
Accessibility
- Focus is trapped and returned. React Aria moves focus into the dialog on open and back to the trigger on close, and contains Tab within it.
- Labelled and described. The title wires aria-labelledby; a description wires aria-describedby. ConfirmDialog is an alertdialog so it reads as a decision.
- Escape and scrim close it, unless isDismissable is off — for a choice the user must make explicitly.
- Scroll is locked on the page behind, so the focus stays on the dialog.
Props
- isOpen / onOpenChange
- Controlled open state and its setter. Escape, scrim, and close all call onOpenChange(false).
- title / description
- The heading (aria-labelledby) and an optional lead paragraph (aria-describedby).
- footer?: ReactNode
- The actions row, right-aligned. Usually a ghost Cancel and a solid-fill primary action.
- size?: ModalSize
- 'sm' | 'md' | 'lg'. Defaults to 'md'.
- isDismissable / showClose
- Both default true. Turn off to force an explicit choice or hide the × button.
- ConfirmDialog
- Adds confirmLabel, cancelLabel, onConfirm, tone (
'default' | 'critical'), and isPending to lock the buttons while the action runs.
Usage
import { Modal, Button } from '@halo-compliance/ui'
import { Check } from 'lucide-react'
const [open, setOpen] = useState(false)
<Button variant="secondary" icon={<SquarePen />} onClick={() => setOpen(true)}>
Edit officer
</Button>
<Modal
isOpen={open}
onOpenChange={setOpen}
title="Edit officer"
description="Update the officer's details."
footer={
<>
<Button variant="ghost" tone="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" icon={<Check />} onClick={save}>
Save changes
</Button>
</>
}
>
{/* a useHaloForm lives here */}
</Modal>import { ConfirmDialog } from '@halo-compliance/ui'
const [open, setOpen] = useState(false)
<ConfirmDialog
isOpen={open}
onOpenChange={setOpen}
tone="critical"
title="Delete this officer?"
confirmLabel="Delete officer"
cancelLabel="Cancel"
onConfirm={() => { deleteOfficer(); setOpen(false) }}
>
This permanently removes the officer and their history. This can't be undone.
</ConfirmDialog>When to use
- A focused, interrupting task. Edit a record, confirm a delete, step through a short flow — work that should block the page.
- A decision that must be made. A ConfirmDialog for anything irreversible: delete, discard, overwrite.
- Not for nav or for hints. Routing belongs in the page; a passing message belongs in a toast or banner.
Anti-patterns
- Stacking modals. A modal opening a modal buries focus — restructure the flow into one dialog.
- A modal for a toast. Save-succeeded does not need to block the page; that is a toast.
- No way out but Confirm. Always give a Cancel; isDismissable off still leaves the footer actions.
- A giant scrolling form. If it needs that much room, it is a page (a Record), not a modal.