npx skills add ...
npx skills add dembrandt/dembrandt-skills --skill modal-and-overlay-patterns
Modals, drawers, bottom sheets and popovers, each with its scope and blocking level. Use when designing dialogs, confirmations, side panels or action sheets.
npx skills add dembrandt/dembrandt-skills --skill modal-and-overlay-patterns
Overlays appear above the main content layer. They range from lightweight popovers (non-blocking, anchored to a trigger) to full blocking modals (require a user response before the app continues). Choosing the right overlay type for the task prevents unnecessary interruption and keeps the user oriented.
Choose the lightest type that satisfies the task. Heavier overlays carry higher cognitive cost.
| Type | Blocks background | Anchored to trigger | Typical content | Dismiss with |
|---|---|---|---|---|
| Tooltip | No | Yes | 1–2 lines of explanatory text | Cursor leave / focus out |
| Popover | No | Yes | Short interactive content: a form field, a picker, a small list | Click outside, Escape, explicit close |
| Dropdown / Menu | No | Yes | List of actions or options | Click outside, Escape, selection |
| Bottom sheet (mobile) | Partial (dimmed) | No | Actions or content on small screens | Swipe down, tap scrim, Escape |
| Drawer / Side panel | Partial (dimmed) | No | Secondary editing, detail views, long forms | Escape, explicit close; optionally click scrim |
| Dialog / Modal | Yes (full scrim) | No | Blocking task: confirm action, fill required form | Escape (non-destructive only), explicit button |
| Full-screen overlay | Yes (complete) | No | Immersive task: media viewer, complex configuration | Explicit close only |
Decision rule: If the user can continue using the rest of the app while the overlay is open, use a non-blocking type (drawer, popover). If the app must wait for the user's response, use a modal.
A tooltip appears on hover or keyboard focus and disappears when the trigger loses focus. It is purely informational — no interactive elements inside.
role="tooltip" on the element; aria-describedby on the trigger pointing to the tooltip id.A popover is anchored to a trigger but contains interactive content — a colour picker, a date range selector, a small form, a list of filters. Unlike a tooltip, it stays open while the user interacts.
role="dialog" (if interactive) or role="listbox" (if a list); aria-haspopup on the trigger.A dropdown lists selectable options or actions anchored to a trigger button. It is the lightest interactive overlay.
↑/↓ to move between items, Enter to select, Escape to close.On small screens, a bottom sheet replaces modals and popovers. It slides up from the bottom edge and feels native to touch devices.
role="dialog" with the same focus management as a modal.A drawer slides in from the left or right and partially covers the main content. Use it for secondary editing tasks, detail views, or settings that the user might refer back to while using the main content.
rgba(0,0,0,0.4)) behind the drawer dims the main content.role="dialog", aria-modal="true", aria-labelledby pointing to the drawer title.A modal blocks the entire UI with a full scrim. Use it only when the app genuinely cannot continue without the user's response.
beforeunload shows the browser's own generic dialog and nothing else. No script runs and no custom markup paints between that dialog and the unload. Do not design a "rate before you leave" or "review your changes" flow around it. Gate on actions the app controls (close button, route change, new search) and treat tab close as an escape hatch.
| Size | Width | Use for |
|---|---|---|
| Small | 360px | Short confirmations, single-field prompts |
| Medium | 480px | Standard dialogs, short forms |
| Large | 640px | Multi-field forms, richer content |
| Full-screen | 100% viewport | Immersive tasks; use sparingly |
Content that overflows the modal height should scroll within the body area only — the header and footer must remain visible.
A mobile-first dialog gets its own desktop layout. A phone-width card centred on a large screen reads cramped and unfinished: options wrap into one narrow column and targets sit closer than the space allows. Give the desktop breakpoint its own width, column count and spacing while mobile keeps the original.
Two or three sub-choices per option go inline. When every option in a picker offers only a couple of second-level choices, render them as always-visible links under the option. An accordion or a second dialog costs a click and hides what is available, and a separate selector for the second dimension lets the user pick combinations that do not exist.
| Trigger | Allowed for non-destructive? | Allowed for destructive? |
|---|---|---|
Escape key | Yes | No — require explicit Cancel |
| Click outside scrim | Yes (optional) | No — too easy to dismiss accidentally |
| Close button (✕) | Yes | Yes |
| Cancel button | Yes | Yes |
For destructive or irreversible actions: disable Escape and click-outside dismissal. The user must explicitly press Cancel or Confirm.
Avoid opening a modal from inside a modal. It signals an information architecture problem — the task is likely too complex for a single dialog.
If a secondary overlay is unavoidable, use a popover anchored inside the modal rather than another full modal. Never stack two blocking scrim overlays.
A lightbox may omit a visible title and body text, but it still has interactive controls (close, paging and zoom) and must remain an accessible named dialog. The rules about scrim, dismissal, focus and text alternatives still apply; zoom and paging are the whole interaction, and they need as much design as any form.
Open at the item that was clicked. Every thumbnail opens the viewer at its own index, not at the first image. Escape closes; focus lands on the close button on open and returns to the thumbnail on close.
Two input models, chosen by pointer type, not by screen width. matchMedia('(pointer: fine)') at
runtime picks the default, because a laptop with a touch screen and a tablet with a trackpad both exist.
It only reports the primary pointer, so keep both models live and let each event's pointerType decide.
Gestures alone are not enough. Visible + / − buttons give the zoom a discoverable and keyboard
reachable path. Disable − at 1× and + at the maximum, so the buttons also tell the user where they
are. Binding the + / − keys and arrow keys for panning is a sound extension of the same rule.
Two-tier zoom. A click or double tap goes to a fixed detail level at the point the user touched. The buttons step further, to a hard maximum. The levels depend on the material: for example 2× and 4× for product photography, further for maps, scans or dense diagrams. A single jump to maximum lands the user on a blur of pixels with no idea where they are.
The cursor announces the next action. Show a zoom-in cursor at 1×, a zoom-out cursor when zoomed, and
an expand cursor on the thumbnails that open the viewer. If the zoom implementation sets its own
cursor: move on the zoomed image, override it, or the hint changes mid-interaction.
Separate a drag from a click. A press that moves more than a few pixels (~5 px) before release is a pan and must not toggle zoom on release, or every pan ends by snapping the image back to 1×.
Zoom belongs to one image. Reset to 1× when the user moves to the next image, and let the swipe reach the carousel again once the image is back at 1×.
Fixed positions for the overlay tools. Close top-right, pager bottom-centre, zoom controls bottom-right. Users learn these once; moving them per screen costs that learning.
Announce the state. Every icon button carries an aria-label. Beyond that, the accessible baseline
is the same as any dialog: expose the current scale to screen readers, in the button label ("Zoom in,
currently 2×") or with a polite live region, and respect prefers-reduced-motion for the zoom
transition. See [[wcag-accessibility]].
Every overlay must manage focus correctly. Broken focus management is one of the most common accessibility failures.
Tab and Shift+Tab cycle within the overlay only; focus cannot escape to the content behind the scrim.Return focus to the element that triggered the overlay. If the trigger no longer exists (the element was deleted), move focus to a logical nearby element.
Use a focus trap library or the native <dialog> element, which handles trapping natively in modern browsers. Rolling a manual focus trap is error-prone.
role="dialog" on the containeraria-modal="true" tells screen readers to ignore content behind the overlayaria-labelledby points to the dialog title's idaria-describedby points to the description's id (optional but helpful for confirmations)aria-hidden="true" — screen readers must not read itConfirmation dialogs for destructive actions must name the item and consequence. Generic "Are you sure?" dialogs don't give enough context.
Do:
Delete "Apollo Project"? This will permanently remove all tasks, files, and history. This cannot be undone. [Cancel] [Delete project]
Don't:
Are you sure you want to do this? [No] [Yes]
--color-error / --color-danger, not --color-primary.A tooltip on one cell of a flush group opens clear of the whole group. When cells sit flush in a grid, treemap or segmented mark, position each cell's tooltip above or beside the entire group, never over a sibling: a tooltip anchored to a bottom-row cell hides the row the reader is about to point at. Make the tooltip ignore the pointer so moving to the neighbour opens the next one.
role="dialog", aria-modal="true", aria-labelledby set correctly?beforeunload?+/− buttons and keys beside the gestures?<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Delete project?</h2>
<p id="modal-description">
This will permanently delete "Apollo" and all its data. This cannot be undone.
</p>
<button>Cancel</button>
<button>Delete</button>
</div>