Overlay
Overlay components interrupt the main flow to focus attention on an isolated task — they appear on top of the page, with a backdrop, and capture focus until dismissed. Reach for them when the user needs to deal with something (edit a record, confirm, pick an option) without losing the background context, yet can't ignore it either.
The three share the same engine (portal to document.body + backdrop + Esc + focus trap + scroll lock) and differ only in anchoring and purpose:
Modal— centered, general purpose.Drawer— anchored to an edge, side panel.BottomSheet— anchored to the bottom, mobile-first.
Everything is portaled
All three render into document.body, outside the tree of the component that opens them. This avoids ancestor overflow: hidden / z-index issues, but means parent-scoped styles don't leak into the overlay.
Modal
When to use: a central flow that pauses context — create/edit a record, a short wizard, a form that demands full attention.
Portal + backdrop + Esc + focus trap + scroll lock.
const [open, setOpen] = useState(false);
<Modal
open={open}
onClose={() => setOpen(false)}
title="Edit profile"
size="md"
footer={
<FormActions>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={save}>Save</Button>
</FormActions>
}
>
<ProfileForm />
</Modal>;
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
title |
ReactNode |
— |
size |
"sm" \| "md" \| "lg" \| "xl" \| "2xl" \| "3xl" |
"md" |
footer |
ReactNode |
— |
fullscreen |
boolean (fills 100dvh regardless of size) |
false |
fullscreenOnMobile |
boolean (becomes fullscreen below 640px) |
false |
dismissOnBackdrop |
boolean |
true |
dismissOnEsc |
boolean |
true |
Safe-area in fullscreen
In fullscreen the Modal applies env(safe-area-inset-*) on all edges, respecting notch and gesture bar. Use fullscreenOnMobile so a dense modal becomes full-screen below 640px instead of cramming into a tiny card.
A11y: role="dialog" + aria-modal="true" + aria-labelledby when title
is a string. Focus is trapped inside the dialog and returns to the trigger on
close.
Drawer
When to use: a persistent side panel that complements the background screen — filters, an item's details, secondary navigation. It hugs an edge instead of centering.
Side drawer. placement: left/right/top/bottom. Auto-switches to a bottom-sheet
on mobile via mobilePlacement.
<Drawer
open={open}
onClose={() => setOpen(false)}
placement="right"
mobilePlacement="bottom" // becomes a bottom sheet on mobile
title="Filters"
showHandle // visual drag indicator when it becomes a bottom-sheet
footer={<Button onClick={apply}>Apply</Button>}
>
<FilterForm />
</Drawer>
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
placement |
"left" \| "right" \| "top" \| "bottom" |
"right" |
mobilePlacement |
"left" \| "right" \| "top" \| "bottom" (mobile override) |
— |
title |
ReactNode |
— |
footer |
ReactNode |
— |
showHandle |
boolean (bottom-sheet style drag indicator) |
false |
hideCloseButton |
boolean |
false |
closeOnBackdrop |
boolean |
true |
closeOnEsc |
boolean |
true |
Drawer sizes to its content, not a size prop
Unlike Modal, Drawer has no size prop — its width/height follows the content (and the placement CSS). For a mobile-first full-width, height-capped panel, prefer BottomSheet or mobilePlacement="bottom".
BottomSheet
When to use: mobile-first actions or choices that rise from the bottom — a share menu, an item's options, a short picker. It's the native iOS/Android pattern.
A modal anchored to the bottom edge — slide-up via animation. Optimized for mobile.
<BottomSheet open={open} onClose={() => setOpen(false)} title="Share">
<Stack gap={3}>
<Button leftIcon={<MessageCircle />}>WhatsApp</Button>
<Button leftIcon={<Mail />}>Email</Button>
<Button leftIcon={<Link />}>Copy link</Button>
</Stack>
</BottomSheet>
| Prop | Type | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
title |
ReactNode |
— |
showHandle |
boolean |
true |
dismissOnBackdrop |
boolean |
true |
dismissOnEsc |
boolean |
true |
Automatic safe-area
BottomSheet adds padding-bottom respecting env(safe-area-inset-bottom), so controls aren't hidden behind the gesture bar on modern iPhones/Androids.
Difference vs Drawer: BottomSheet is always slide-up + max-height 90dvh + a
drag handle. Use Drawer when you need a variable placement (side/top) or
different behavior between desktop and mobile.
Be careful turning off closeOnBackdrop/dismissOnBackdrop
Disabling backdrop or Esc dismissal traps the user in the overlay until the task is done. Do this only for truly critical forms (data loss) — otherwise always offer a clear exit, or keyboard navigation becomes a trap.
ModalsManager
When to use: when you want to open modals and confirmations imperatively — straight from a handler, without wiring a
<Modal open={...}>controlled by local state everywhere. Ideal for delete confirmations and one-off dialogs.
<ModalsProvider> mounts once near the root and manages a stack of modals;
useModals() exposes the imperative API over the existing Modal and
ConfirmDialog components.
import { ModalsProvider, useModals, Button } from "tempest-react-sdk";
// app root
<ModalsProvider>
<App />
</ModalsProvider>;
// in any component below the provider
function DeleteButton({ id }: { id: string }) {
const modals = useModals();
return (
<Button
variant="danger"
onClick={() =>
modals.confirm({
title: "Delete item",
message: "This action cannot be undone. Continue?",
confirmLabel: "Delete",
danger: true,
onConfirm: async () => {
await fetch(`/api/items/${id}`, { method: "DELETE" });
},
})
}
>
Delete
</Button>
);
}
useModals() |
Signature | What it does |
|---|---|---|
open |
(options: OpenModalOptions) => string |
Pushes a content modal; returns the id. |
confirm |
(options: ConfirmModalOptions) => string |
Pushes a ConfirmDialog; returns the id. |
close |
(id: string) => void |
Removes the modal with that id. |
closeAll |
() => void |
Removes every modal from the stack. |
Built on the existing components
open renders a Modal and confirm renders a ConfirmDialog — you inherit
focus trap, scroll lock, Esc and backdrop with zero setup. onConfirm can be
async: the dialog shows loading until the promise resolves and closes
itself when it's done.
Needs <ModalsProvider> above
useModals() throws if called outside a <ModalsProvider>. Mount the provider
once near the app root.
General A11y
- Focus trap: Tab cycles only inside the dialog. Restores focus to the trigger on close.
- Scroll lock:
body.overflow = "hidden"while open. - Esc closes (
Modal/BottomSheet:dismissOnEsc={false};Drawer:closeOnEsc={false}). aria-modal="true"tells screen readers the rest of the page is blocked.- Backdrop: clicks close it (
Modal/BottomSheet:dismissOnBackdrop={false};Drawer:closeOnBackdrop={false}).
Lightbox
When to use it: view a photo full-screen with navigation — a property gallery, the attachments of a ticket, inspection photos.
A role="dialog" aria-modal overlay with focus trapped inside and the page scroll locked. Only the current image is mounted; the neighbours are preloaded via Image(), so pressing → does not flash an empty frame.
import { Lightbox } from "tempest-react-sdk";
import { useState } from "react";
export function InspectionGallery({ photos }: { photos: { url: string; description: string }[] }) {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
return (
<>
<div className="tempest-grid-auto">
{photos.map((photo, i) => (
<button key={photo.url} type="button" onClick={() => { setIndex(i); setOpen(true); }}>
<img src={photo.url} alt={photo.description} className="tempest-aspect-square" />
</button>
))}
</div>
<Lightbox
open={open}
items={photos.map((p) => ({ src: p.url, alt: p.description }))}
index={index}
onIndexChange={setIndex}
onClose={() => setOpen(false)}
/>
</>
);
}
| Prop | Type | Default | What it does |
|---|---|---|---|
items |
LightboxItem[] |
— | Gallery images. |
open |
boolean |
— | Controls visibility. |
index |
number |
0 |
Index being shown. |
onIndexChange |
(index: number) => void |
— | Passing this makes the index controlled. |
onClose |
() => void |
— | Called on Esc and on the close button. |
showThumbnails |
boolean |
true if > 1 item |
Thumbnail strip. |
showCounter |
boolean |
true |
The 3 / 12 counter. |
loop |
boolean |
true |
Wraps around at the ends. |
LightboxItem = { src, alt, caption?, thumbnail? } — alt is required: a gallery of unlabeled images is unusable with a screen reader.
Keyboard: Esc closes · ←/→ navigate · Home/End jump to the ends.
loop is true on purpose
In a photo viewer, hitting a dead end at the last image reads as a bug more often than as a boundary. Pass loop={false} when order carries meaning (a step-by-step, say) — then the nav buttons disable at the ends.
Recap
| Component | Anchoring | Purpose | Dismiss props |
|---|---|---|---|
Modal |
centered | central flows (create/edit) | dismissOnBackdrop/dismissOnEsc |
Drawer |
edge (variable) | persistent side panels | closeOnBackdrop/closeOnEsc |
BottomSheet |
bottom edge | mobile-first actions (share) | dismissOnBackdrop/dismissOnEsc |
ModalsManager |
stack (imperative) | open modals/confirmations from code | useModals().close/closeAll |
For a pre-built destructive confirmation, use ConfirmDialog (actions), built on top of Modal. To open modals imperatively (no local state), use <ModalsProvider> + useModals().
Related: actions (ConfirmDialog, buttons in the footer) · inputs (forms inside the overlay) · navigation (Drawer as secondary nav).