Skip to content

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 & Toast in the gallery

Section modal of the gallery — run it locally to interact.

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.

import { useState } from "react";
import { Button, FormActions, Modal } from "tempest-react-sdk";

export function EditarPerfil({ save }: { save: () => void }) {
    const [open, setOpen] = useState(false);

    return (
        <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>
            }
        >
            <p>The profile form goes here.</p>
        </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
closeOnBackdrop boolean true
closeOnEsc 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

AppBar · Tabs · Tooltip · Drawer in the gallery

Section navigation of the gallery — run it locally to interact.

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.

import { useState } from "react";
import { Button, Drawer } from "tempest-react-sdk";

export function GavetaDeFiltros({ apply }: { apply: () => void }) {
    const [open, setOpen] = useState(false);

    return (
        <Drawer
            open={open}
            onClose={() => setOpen(false)}
            placement="right"
            mobilePlacement="bottom"
            title="Filters"
            showHandle
            footer={<Button onClick={apply}>Apply</Button>}
        >
            <p>The profile form goes here.</p>
        </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

Alert · Timeline · BottomSheet in the gallery

Section feedback-extra of the gallery — run it locally to interact.

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.

import { useState } from "react";
import { BottomSheet, Button, Stack } from "tempest-react-sdk";
import { Link, Mail, MessageCircle } from "lucide-react";

export function Compartilhar() {
    const [open, setOpen] = useState(false);

    return (
        <BottomSheet open={open} onClose={() => setOpen(false)} title="Share">
            <Stack gap={3}>
                <Button leftIcon={<MessageCircle size={16} />}>WhatsApp</Button>
                <Button leftIcon={<Mail size={16} />}>Email</Button>
                <Button leftIcon={<Link size={16} />}>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.

Fullscreen

Every SDK overlay mounts through a portal, and the portal target follows the fullscreen element whenever there is one — Modal, Drawer, BottomSheet, ToastProvider, Command, ContextMenu, DropdownMenu, Popover, Tooltip, HoverCard, Combobox, MultiSelect, NavigationMenu, Menubar and the generic <Portal>.

This is not convenience, it is correctness. While the page is in fullscreen the browser paints only the fullscreen element's subtree, and document.body is outside it. An overlay mounted in body exists in the DOM, has a measured box, and is neither seen nor clicked — elementFromPoint at its centre returns whatever sits behind it. Nothing throws and nothing reaches the console.

import { useRef, useState } from "react";
import { Button, Modal } from "tempest-react-sdk";

export function Call() {
    const stage = useRef<HTMLDivElement>(null);
    const [open, setOpen] = useState(false);

    return (
        <div ref={stage}>
            <Button onClick={() => stage.current?.requestFullscreen()}>Fullscreen</Button>
            <Button onClick={() => setOpen(true)}>Audio and video</Button>

            <Modal open={open} onClose={() => setOpen(false)} title="Audio and video">
                The dialog shows up inside the fullscreen element.
            </Modal>
        </div>
    );
}

The target follows, it is not read once

Entering or leaving fullscreen with a dialog already open moves the dialog: the host is state and listens for fullscreenchange (plus the webkitfullscreenchange WebKit still emits). The dialog stays mounted and keeps its state.

container still wins

<Portal container={…}> pins the target and ignores fullscreen. Use it when you need a specific destination — a layout root, a node outside an overflow.

General A11y

  • Focus trap (Modal, Drawer, BottomSheet): focus enters the dialog on open, Tab/Shift+Tab cycle only inside it — portalled panels opened from it included — and focus returns to the trigger on close. See Focus stays inside, portalled panels included.
  • Scroll lock: body.overflow = "hidden" while open.
  • Esc closes the top layer (Modal/Drawer: closeOnEsc={false}; BottomSheet: dismissOnEsc={false}) — see Escape closes one layer at a time.
  • aria-modal="true" tells screen readers the rest of the page is blocked.
  • Backdrop: clicks close it (Modal/Drawer: closeOnBackdrop={false}; BottomSheet: dismissOnBackdrop={false}).

Focus stays inside, portalled panels included

Once open, the Modal puts focus on its first focusable element (here, the header's Close button — labelled "Fechar"). From then on, Tab and Shift+Tab only move inside the dialog:

import { useState } from "react";
import { Button, Input, Modal, Popover } from "tempest-react-sdk";

export function Filters({ apply }: { apply: () => void }) {
    const [open, setOpen] = useState(false);

    return (
        <>
            <Button onClick={() => setOpen(true)}>Open filters</Button>
            <Modal
                open={open}
                onClose={() => setOpen(false)}
                title="Filters"
                footer={<Button onClick={apply}>Apply</Button>}
            >
                <Popover trigger={<Button variant="secondary">Period</Button>}>
                    <Input aria-label="From" />
                    <Input aria-label="To" />
                </Popover>
            </Modal>
        </>
    );
}

With the Popover open its panel lives at the end of body, outside the dialog — and it still joins the cycle right after its trigger: Period → From → To → Apply → Close → Period. A Modal opened from inside another traps focus until it closes and then hands it back to the button that opened it, inside the one below. On close, focus returns to Open filters.

Before 0.68.0, Modal, Drawer and BottomSheet declared aria-modal="true" without trapping focus. Measured in Chrome, with real key presses, on the gallery's modal section:

Moment Before Now
Right after opening focus on the opening button, outside the dialog the dialog's first focusable
Tab ×5 5 stops on the page behind the backdrop cycles through the dialog's focusables
On close wherever Tab had left it the opening button

The screen reader heard that the page was blocked while the keyboard kept using it. Command already trapped focus, but on close it returned it to body: the search field's autoFocus took focus before the trap noted where it came from.

A dialog with nothing focusable

With hideCloseButton and text-only content, focus goes to the dialog itself (tabIndex={-1}), and Tab stays on it instead of escaping.

Escape closes one layer at a time

One Escape closes only the most recently opened layer. The next Escape closes the one below:

import { useState } from "react";
import { Button, DropdownMenu, Modal } from "tempest-react-sdk";

export function EditOrder({ cancel }: { cancel: () => void }) {
    const [open, setOpen] = useState(true);

    return (
        <Modal open={open} onClose={() => setOpen(false)} title="Edit order">
            <DropdownMenu
                trigger={<Button variant="ghost">More actions</Button>}
                items={[{ type: "item", id: "cancel", label: "Cancel order", onSelect: cancel }]}
            />
        </Modal>
    );
}

With the menu open, the first Escape closes the menu and returns focus to the trigger inside the modal. The second one closes the modal.

Before 0.67.0 every overlay listened for keydown on window and closed on any Escape, so one key press closed everything that was open. Measured in Chrome, with a real key press:

Inside Inner layer Before Now
Modal DropdownMenu, Combobox, MultiSelect, Popover, ContextMenu, editable DataTable cell closed both closes only the inner one
Modal another Modal closed both closes only the top one
Drawer Popover closed both closes only the Popover

The rule covers Modal, Drawer, BottomSheet, Lightbox, Popover, ContextMenu, NavigationMenu, Menubar and Tooltip. Components that handle Escape on their own element (the Combobox list, the cell editor, DropdownMenu) consume the key first, and the outer overlay does not react.

closeOnEsc={false} blocks, it does not pass through

A Modal (or Drawer) with Escape turned off stays on top of the stack and swallows the key. Otherwise Escape would fall through and close the modal underneath while the top one stays open.

Your own Escape handler works with the stack

If a component of yours handles Escape inside an overlay, call event.preventDefault(). The stack ignores a consumed key, so the outer overlay stays open.

SignaturePad · Lightbox · AvatarGroup in the gallery

Section capture-media of the gallery — run it locally to interact.

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) closeOnBackdrop/closeOnEsc
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).