modal-and-overlay-patterns
Overlays — modals, drawers, bottom sheets, popovers — interrupt or augment the main flow. Each type has a different scope, blocking level, and appropriate use case. Use when designing dialogs, confirmation prompts, side panels, action sheets, or any UI element that appears above the main content lay
By dembrandt · 457 installs
npx skills add dembrandt/dembrandt-skills --skill modal-and-overlay-patterns
Source repository · Upstream listing
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.
The Overlay Hierarchy
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.
One Shell for the Whole Family
Tooltip, popover, drawer, bottom sheet and dialog are one family, not five components. They share a single shell: the same raised surface, one hairline border, one corner radius, and a shadow that grows with modality. The ones that carry a header and footer share what sits inside: a title that names the object, a hairline under the header, and one footer order used everywhere. Only size, anchoring (arrow or centred) and blocking change between them.
When each overlay is styled on its own, the user meets several dialects of the same message: different radii, button orders and confirm labels. Each one costs a moment of re learning and makes a destructive confirm easier to misread. One shell is also one code primitive and one design tool family, so a fix lands everywhere.
Audit before adding. List every floating surface in the product and the tokens it uses. Extract the shell into one primitive and build each overlay type on it. The usual find is a confirm popover and a dialog that disagree on footer order, or a dialog header still carrying a heavy rule the rest of the UI has dropped.
Tooltip
A tooltip appears on hover or keyboard focus and disappears when the trigger loses focus. It is purely informational — no interactive elements inside.
Content: one short sentence, label, or keyboard shortcut. Never put a link or button inside a tooltip.
Delay: 300–400ms on hover; no delay on keyboard focus.
Position: prefer above the trigger; auto flip when viewport edge is near.
ARIA: role="tooltip" on the element; aria describedby on the trigger pointing to the tooltip id.
Popover
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.
Max width: 280–360px. For larger content, use a drawer.
Position: anchored to the trigger; auto flip to stay in viewport.
Dismiss: click outside, Escape key, or explicit close button when the content is long.
Focus: move focus into the popover when it opens; return focus to the trigger on close.
ARIA: role="dialog" (if interactive) or role="listbox" (if a list); aria haspopup on the trigger.
Dropdown and Menu
A dropdown lists selectable options or actions anchored to a trigger button. It is the lightest interactive overlay.
Separate select dropdowns (the user picks one value that persists) from action menus (the user triggers an action that doesn't persist as a value).
Width: at least as wide as the trigger; cap at 280px.
Long lists: add a search input at the top when there are more than 8–10 items.
Keyboard: ↑ / ↓ to move between items, Enter to select, Escape to close.
Bottom Sheet (Mobile)
On small screens, a bottom sheet replaces modals and popovers. It slides up from the bottom edge and feels native to touch devices.
Peek height: Show a small portion of the sheet first (a handle + title), let the user drag to expand.
Full height: For longer content or forms that need the full viewport.
Dismiss: swipe down, tap the scrim, or press Escape.
Do not centre dialogs on mobile — use a bottom sheet instead (centred modals are too small and hard to reach).
ARIA: treat as role="dialog" with the same focus management as a modal.
Drawer / Side Panel
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.
Right drawer: Detail view, editing form, filter/sort panel. Most common.
Left drawer: Navigation on mobile (hamburger menu pattern).
Width: 320–480px on desktop. Full width on mobile (effectively a bottom sheet or full screen overlay instead).
Scrim: a semi transparent backdrop ( rgba(0,0,0,0.4) ) behind the drawer dims the main content.
Dismiss: Escape key, explicit close button. Clicking the scrim is optional — avoid it when the drawer contains an unsaved form.
Do not use a drawer when the task is blocking (e.g. a required decision). Use a modal instead.
ARIA: role="dialog" , aria modal="true" , aria labelledby pointing to the drawer title.
Modal / Dialog
A modal blocks the entire UI with a full scrim. Use it only when the app genuinely cannot continue without the user's response.
When to use a modal
Confirming a destructive or irreversible action
A required form that must be submitted before continuing
An error or warning that requires the user's acknowledgement
When not to use a modal
Displaying information the user can read at their leisure → use an inline notice or notification
A task the user might want to do alongside the main content → use a drawer
A large form with many fields → use a dedicated page or a drawer
The tab close case
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.
Anatomy
Sizing
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.
Dismiss behaviour
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.
Stacking modals
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.
Lightbox / Media Viewer
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.
Fine pointer: a click toggles zoom at the pointer position. Pan either by moving the pointer (fast, but
the image shifts whenever the user reaches for the close button) or by dragging (calmer, one more
gesture to learn). Pick one and keep it across the product.
Coarse pointer: pinch zooms, double tap toggles, drag pans. These are what the platform taught the user.
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]].
Focus Management
Every overlay must manage focus correctly. Broken focus management is one of the most common accessibility failures.
On open
1. Move focus to the first interactive element inside the overlay (usually the first field, or th