Click to open the confirmation modal.
Click to confirm
Overview
Three components live in @flatpay-dk/ui: <Modal>, <Sheet>, and <Drawer>. They share the same internal vocabulary — ModalHeader (aliased as SheetHeader / DrawerHeader), ModalBody, and ModalFooter — so the markup reads naturally on whichever surface you pick. Each renders into a portal, draws a 35 % charcoal scrim, traps focus, locks body scroll, and returns focus to the trigger when it closes.
Reach for them last
Per the Elevation foundation: a surface is borders + flow first; shadows when it has to float; modals when it has to interrupt. They’re the heaviest pattern in the system — use them only when the user has to commit, decide, or attend to one thing before continuing.
Choosing the right one
One decision tree, three answers. Most prototypes need Modal or Sheet; Drawer is the lower-level escape hatch when you want explicit control over which edge a panel sits on.
<Modal>Confirmation, alert, destructive decision. The “Are you sure?” pattern. Short, focused, time-critical.Centred at every viewport. No layout switch — the same modal renders on a 375 px phone and a 1440 px desktop.<Sheet>Content, input, long-form text, detail view. The “Edit this thing” pattern. Needs more room than a confirmation.Below md (768 px): bottom drawer with rounded top corners. At md+: centred modal. Automatic — consumers never write their own media-query logic.<Drawer>Explicit fixed-side panel — left for navigation menus, right for filters, top/bottom for system messages. Lower-level primitive.None. The side and size you pass are what renders at every viewport.Anatomy
Three slots. Use as many as the surface needs — a delete confirm can be header + footer; a filter drawer is all three.
- Header.Inter Tight Bold 24 px title + optional 16 px secondary description + a trailing close button. The title becomes the dialog’s accessible name via
aria-labelledby. - Body. The form, list, or content the dialog wraps. Scrolls independently when it overflows so the header and footer stay visible.
- Footer. Cancel + commit, both real
<Button>s.layout="row"right-aligns them with a 12 px gap (desktop default);layout="stack"stacks them full-width with the primary on top (mobile alerts).
Modal sizes
Three sizes that sit just inside the Tailwind breakpoint rungs: sm (400 px) for alerts, md (560 px, default) for short forms, and lg (720 px) when the form genuinely needs the room. Every value is clamped to 100vw - 32pxso a modal can’t reach the viewport edges on a phone. Above lg, reach for <Sheet> or a full page.
Form modal at size="md".
Form modal · md
Sheet (responsive)
<Sheet> is the responsive surface for content and inputs. Below md (768 px) it anchors to the bottom of the viewport with rounded top corners and a slide-up entrance, leaving the page peeking behind. At md+ it's a centred modal with the standard fade-in. The size prop drives max-height on mobile (sm 40vh, md 60vh, lg 85vh) and width on desktop (sm 480, md 640, lg 768). One component, one layout decision the system handles for you.
Resize the window and re-open — below md (768 px) the sheet anchors to the bottom edge; at md+ it's a centered modal. Same component, same content.
Resize the window
When in doubt: Sheet
If the surface carries content the user reads, scans, or fills in, default to Sheet. Modal is the right pick only when the surface poses one direct question (delete, invite, confirm). Drawer is the right pick only when you specifically need a side-attached panel that doesn't adapt.
Drawer sides
Four sides — right (default for desktop filters and detail surfaces), left (occasional — rarely the right call when the main nav is already on the left), bottom (the mobile sheet), and top (rare, for system-wide announcements). Each side gets its own slide-in animation that respects prefers-reduced-motion.
side="right" size="md"
Right · desktop filter panel
side="bottom" · the mobile pattern
Bottom · mobile sheet
Footer layout
Two layouts. Use layout="row" (default) on desktop and on any non-mobile alert; switch to layout="stack" on mobile alerts and bottom-sheet drawers, where the buttons should reach the full width and the primary should sit on top.
Are you sure?
This can’t be undone.
layout="row" · desktop defaultAre you sure?
This can’t be undone.
layout="stack" · mobile alertBehavior
- Open. Both are controlled — flip
opentotrue. The first focusable element receives focus on mount; if there isn’t one, the panel itself takes it. - Close. Three paths: click the close button, click the scrim, press Escape. Set
dismissOnScrimClick={false}when an outside click could lose unsaved data. - Focus return. When the dialog closes, focus flows back to whatever element opened it. Built in — no consumer wiring required.
- Tab cycles. Tab and Shift+Tab cycle through focusable elements inside the panel and never escape it. The page underneath is inert while the dialog is open.
- Body scroll lock.
document.body.style.overflowis set tohiddenwhile open and restored on close. The body doesn’t scroll behind the dialog. - Animation. 200 ms ease-out-quart entrance: modal fades + scales from 0.97; drawer slides from its edge. The exit reverses. Reduced motion shortcuts both.
Accessibility
- Roles.
role="dialog"witharia-modal="true"on the panel.aria-labelledbypoints at the title;aria-describedbyat the description if there is one. - Close button. Always carries an
aria-label="Close dialog"even though the X glyph is recognisable — screen readers need the verb. - Focus indicator.Every focusable inside shows the system 2 px blurple focus ring with a 2 px offset. Don’t override it — keyboard users navigate the panel by it.
Code
tsx
import {
Modal, ModalHeader, ModalBody, ModalFooter,
Drawer, DrawerHeader, DrawerBody, DrawerFooter,
Button,
} from "@flatpay-dk/ui";
// Confirm-delete alert (sm) — the default, blocking shape
const [confirmOpen, setConfirmOpen] = useState(false);
<Modal open={confirmOpen} onOpenChange={setConfirmOpen} size="sm">
<ModalHeader
title="Delete prototype"
description="lucky-garden will be removed. Anyone with the link will see a 404."
/>
<ModalFooter layout="row">
<Button variant="tertiary" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={onDelete}>Delete</Button>
</ModalFooter>
</Modal>
// Form modal (md) — header + scrolling body + footer
<Modal open={open} onOpenChange={setOpen} size="md">
<ModalHeader title="Invite teammate" description="They'll get an email." />
<ModalBody>
<FormFields />
</ModalBody>
<ModalFooter layout="row">
<Button variant="tertiary" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={onSubmit}>Send invite</Button>
</ModalFooter>
</Modal>
// Right drawer — desktop filter / detail panel
<Drawer open={open} onOpenChange={setOpen} side="right" size="md">
<DrawerHeader title="Filter prototypes" description="Narrow the catalog." />
<DrawerBody>{filters}</DrawerBody>
<DrawerFooter layout="row">
<Button variant="tertiary" onClick={onReset}>Reset</Button>
<Button onClick={() => setOpen(false)}>Apply</Button>
</DrawerFooter>
</Drawer>
// Bottom sheet — the mobile pattern
<Drawer open={open} onOpenChange={setOpen} side="bottom" size="sm">
<DrawerHeader title="Restart terminal?" />
<DrawerFooter layout="stack">
<Button onClick={onRestart}>Restart</Button>
<Button variant="tertiary" onClick={() => setOpen(false)}>Cancel</Button>
</DrawerFooter>
</Drawer>Best practices
- Title is the question or the verb. Delete prototype, not Confirmation. Invite teammate, not Form. The title is what the user reads first; make it the answer.
- Primary action mirrors the title verb. A modal titled Delete prototype commits with Delete, not Confirm. The user reads the verb twice — same word both times.
- Two actions, three at most. A modal with four buttons is a page in disguise. If you need more choices, the surface should be a real page or a drawer.
- Don’t nest dialogs. A modal that opens another modal is a code smell. Close the first or redesign the flow into a wizard inside one drawer.
- Lock dismissal when there’s unsaved input. Pass
dismissOnScrimClick={false}and confirm-on-close inside the dialog. Quietly losing data is the worst-case modal experience.
Props
<Modal>
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controlled open state. |
| onOpenChange* | (open: boolean) => void | — | Fires when the dialog wants to open or close. |
| size | "sm" | "md" | "lg" | "md" | sm = alert (360 px), md = form (512 px), lg = wider form (720 px). All capped to the viewport on small screens. |
| dismissOnScrimClick | boolean | true | Whether clicking the scrim closes. Set false when there's unsaved input. |
| labelledBy | string | — | Override the auto-generated aria-labelledby id. |
| describedBy | string | — | Override the auto-generated aria-describedby id. |
| children* | ReactNode | — | Header, Body, Footer. |
<Drawer>
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controlled open state. |
| onOpenChange* | (open: boolean) => void | — | Fires when the drawer wants to open or close. |
| side | "right" | "left" | "bottom" | "top" | "right" | Which edge the drawer is attached to. |
| size | "sm" | "md" | "lg" | "md" | Cross-axis size — width for right/left, height for top/bottom. |
| dismissOnScrimClick | boolean | true | Whether clicking the scrim closes. |
| labelledBy | string | — | Override the auto-generated aria-labelledby id. |
| children* | ReactNode | — | Header, Body, Footer. |
<ModalHeader> / <DrawerHeader>
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | ReactNode | — | The dialog's accessible name. Inter Tight Bold 24 px / 36 px line-height. |
| description | ReactNode | — | Optional supporting line. 16 px secondary. |
| showClose | boolean | true | Whether to render the trailing close button. Set false on alert modals where the buttons are the only exit. |
<ModalFooter> / <DrawerFooter>
| Prop | Type | Default | Description |
|---|---|---|---|
| layout | "row" | "stack" | "row" | row right-aligns the buttons (desktop default); stack stacks them full-width with the primary on top (mobile alerts). |