Overlay
The behaviour Modal, Popover, Menu and Tooltip share: how they close, where focus goes, and when it is trapped. It is a hook with no markup, so you only reach for it when building a new overlay, never to style one.
Live preview
Rendered by the component itself from the same generated tokens as the rest of the site.
// Popover calls useOverlay({ open, onDismiss, trapFocus: false })
<Popover
trigger={(props) => <Button {...props}>Open panel</Button>}
>
<ThemeOptions value={themes} onChange={setThemes} />
</Popover>Installation
Install it with the shadcn CLI, along with everything it uses. New project? Set it up first, as Quick start shows.
npx shadcn@latest add https://www.graphite-ui.com/r/overlay.jsonimport { useOverlay } from '@/components/ui/overlay'
import type { DismissOptions } from '@/components/ui/overlay'Anatomy
Overlay has no slots because it draws nothing. It is one hook, useOverlay, that each overlay calls with its own switches instead of wiring its own listeners, and this is what each one turns on. Notification’s contract cites the same base, but it sits inline with nothing to dismiss, so it does not call the hook.
API reference
Generated from the contract, versioned with it, and checked by drift-check. This table cannot describe props the component does not have.
| Prop | Type | Default | Notes |
|---|---|---|---|
| dismissOn | 'escape' | 'outside' | 'close-control' | — | Which dismissals a given overlay honours. Every overlay honours at least one. |
| trapFocus | boolean | — | Modal overlays trap; non-modal ones do not. |
Design tokens
Every swatch is live. Change the source color in the header and this table repaints, because it reads the same roles the component does.
The contract declares no tokens: this component draws nothing of its own.
Usage
The contract's prohibitions, written as the choices you will actually face.
Do
- Call useOverlay from any new floating surface, and pass it switches rather than adding listeners of your own.
- Turn trapFocus on for anything that blocks the page, and leave it off for anything the reader can ignore.
- Declare elevation and shadow in the new overlay’s own contract: elevation-01 for the surface and shadow-overlay for the lift, with no edge, as the kit draws its overlays.
- Unmount the content on close, as the four existing overlays do. Their no-nesting checks depend on it.
Don’t
- Give one instance its own way of closing. An overlay that needs a different pattern is a different component, not a variant.
- Keep closed content mounted to animate it out. That would move the nesting checks onto the prerender path.
- Animate transform on the way in. Tooltip carries its placement in transform, so the shared entrance is opacity only.
- Send focus somewhere new on close. It goes back to whatever held it when the overlay opened, every time.
Accessibility
What the component does for you, and what it leaves to you.
- Escape
- Open overlays stack in the order they opened, and Escape closes only the top one, so each press peels off one layer. A Modal with dismissible off still takes the press, so nothing beneath it closes either.
- Focus return
- When an overlay closes, focus goes back to whatever held it when the overlay opened, whether it was trapped or not. The exception is focus the reader has already moved elsewhere: Tab away from a Tooltip’s trigger and focus stays where the Tab put it.
- Trapping
- With trapFocus on, the overlay takes focus as it opens, and Tab and Shift+Tab wrap between its first and last focusable elements. Only the topmost trap wraps Tab.
- Outside press
- With outside on, a press outside the overlay closes it. The control that opened it does not count as outside, so its own click can toggle it shut. A press inside an overlay stacked above does not count either, so a Menu in a Modal closes on a press elsewhere in the Modal while the Modal stays. Nothing reaches past a trapping overlay.
- Roles
- The hook sets no roles and no labels. Each overlay adds its own: dialog, menu or tooltip.
- Re-renders
- An open overlay can re-render freely. The hook reads the latest onDismiss when it needs it, so an inline one is fine, and focus stays where the reader put it.
