Components · Overlays

Modal, sheet & drawer

Three intent-named siblings — same shell, same accessibility, three jobs. Modal is the centred dialog for confirmations and alerts; Sheet is the responsive content surface (bottom drawer on phone, centred modal on desktop); Drawer is the explicit fixed-side panel for nav menus and filter sidebars.

Documentedby Derek Fidler

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.

ComponentUse it forResponsive behaviour
<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).

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

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 default

Are you sure?

This can’t be undone.

layout="stack" · mobile alert

Behavior

  • Open. Both are controlled — flip open to true. 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.overflow is set to hidden while 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" with aria-modal="true" on the panel. aria-labelledby points at the title; aria-describedby at 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>

PropTypeDefaultDescription
open*booleanControlled open state.
onOpenChange*(open: boolean) => voidFires 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.
dismissOnScrimClickbooleantrueWhether clicking the scrim closes. Set false when there's unsaved input.
labelledBystringOverride the auto-generated aria-labelledby id.
describedBystringOverride the auto-generated aria-describedby id.
children*ReactNodeHeader, Body, Footer.

<Drawer>

PropTypeDefaultDescription
open*booleanControlled open state.
onOpenChange*(open: boolean) => voidFires 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.
dismissOnScrimClickbooleantrueWhether clicking the scrim closes.
labelledBystringOverride the auto-generated aria-labelledby id.
children*ReactNodeHeader, Body, Footer.

<ModalHeader> /  <DrawerHeader>

PropTypeDefaultDescription
title*ReactNodeThe dialog's accessible name. Inter Tight Bold 24 px / 36 px line-height.
descriptionReactNodeOptional supporting line. 16 px secondary.
showClosebooleantrueWhether to render the trailing close button. Set false on alert modals where the buttons are the only exit.

<ModalFooter> /  <DrawerFooter>

PropTypeDefaultDescription
layout"row" | "stack""row"row right-aligns the buttons (desktop default); stack stacks them full-width with the primary on top (mobile alerts).