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.

Confirmed — a real handler would call the delete API. (Status: 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--sheet surface — 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-shadow token 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

A modaltsx
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>
A destructive confirmationtsx
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.
  • /button — the solid-fill triggers and the footer actions.
  • /card — the same flat, solid surface the panel is built from.
  • /record — where the danger zone fires a critical ConfirmDialog.