Topic 24.8
Modals, Toasts, Tooltips and Menus
In one line
Overlays float above the page, so they must escape clipping and stacking (portals or the browser's top layer), manage focus (trap it in modals, return it on close), dismiss predictably (Escape, outside click), position themselves next to their trigger without leaving the screen, and, for toasts, queue up, time out and pause while the user reads.
Think of it like this
Notes and signs in an office. A modal is a manager standing at your desk: you can't carry on with anything else until you've answered, and afterwards you're back exactly where you were. A toast is a sticky note someone slaps on your monitor: you can ignore it, and it falls off by itself after a while, unless you're holding it to read. A tooltip is the label on a light switch: tiny, right next to the thing, there when you look. A menu is a drawer you pull open and push shut.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Overlay
- Any UI that floats above the page: modals, toasts, tooltips, menus, popovers.
- Stacking context
- A group of elements whose
z-indexvalues only compete with each other, created by things liketransformoropacity. - Portal
- React rendering a component's DOM somewhere else (like
document.body) while it stays in its place in the React tree. - Top layer
- A browser layer above all page content used by modal dialogs and popovers, unaffected by
z-index. - Inert
- Not interactive: can't be clicked, focused or read by screen readers.
- Focus trap
- Keeping keyboard focus cycling inside a modal while it's open.
- Light dismiss
- Closing a popup by clicking outside it or pressing Escape.
- Toast
- A small, temporary message that appears in a corner and disappears by itself.
Step by step
01Get out of the parent's box
Clipping and stacking contexts come from ancestors. Render overlays in a portal at the end of <body>, or let the browser's top layer handle it with <dialog> and popover.
02Drive the native dialog from React state
State says whether it's open; an effect calls showModal() or close(); onClose keeps state in sync when the browser closes it (Escape).
03Queue toasts and time them
A provider keeps an array; toast() appends. Render the first few; each visible toast runs its own timer, paused on hover and focus.
04Measure, place, flip
In useLayoutEffect, read the trigger's rectangle and the tip's height, choose top or bottom, and set fixed coordinates before the browser paints.
const t = trigger.getBoundingClientRect();
const placement = t.top - tipHeight - GAP >= 0 ? "top" : "bottom";
const top = placement === "top" ? t.top - tipHeight - GAP : t.bottom + GAP;
const left = Math.min(Math.max(8, t.left), window.innerWidth - tipWidth - 8); // shift inside05Use popover for simple menus
popoverTarget on a button and popover="auto" on the panel give top-layer rendering and light dismiss for free.
Try it yourself
- 1
Remove onClose
Delete
onClose={onClose}from the<dialog>. Open the modal, press Escape, then click Delete account again. Predict what happens: nothing, because React state still saysopenis true, so the effect doesn't run again. - 2
Change the queue size
Set
MAX_VISIBLEto 5 and predict the three log lines. Then setDURATIONto 2000 and check that hovering is no longer needed to keep the first toast. - 3
Put the tooltip inside a clipped box
Render the tooltip without
createPortalinside adivwithoverflow: hidden; transform: translateZ(0). Hover it: the tip is cut off, becausetransformmakes the box the containing block forposition: fixedchildren.
Code & diagrams
`showModal()` moves focus to the `autoFocus` button and makes the page inert. The script simulates Escape (a `cancel` event, then the browser closes the dialog); `onClose` sets the state, and the browser puts focus back on the opener. Open it yourself and press Tab and Escape.
Preview
Console
Nothing logged yet.Expected console output
Open: modal true, focus on Cancel
After Escape: open false, focus back on openerFive toasts are queued; three show. Hovering the first pauses its timer, so after 500ms it's still there while 2 and 3 have gone and 4 and 5 moved up. After the pointer leaves, it finishes its remaining time. `<ToastContext value={show}>` is the React 19 way to provide a context.
Preview
Console
Nothing logged yet.Expected console output
Right after 5 saves: Saved file 1, Saved file 2, Saved file 3
After 500ms, hovering the first: Saved file 1, Saved file 4, Saved file 5
Later: noneThe Edit button sits at the top edge, so its tooltip flips below; Share has room above. The tooltip appears on focus as well as hover. The Menu uses the `popover` attribute: click outside or press Escape to close it.
Preview
Console
Nothing logged yet.Expected console output
Edit tooltip placed on bottom
Share tooltip placed on top
Menu open: trueimport { useState } from "react";
import { useFloating, autoUpdate, offset, flip, shift, useHover, useFocus, useDismiss, useRole, useInteractions, FloatingPortal } from "@floating-ui/react";
import { Toaster, toast } from "sonner";
export function Tooltip({ label, children }) {
const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })], // gap, flip when no room, stay on screen
whileElementsMounted: autoUpdate, // re-position on scroll and resize
});
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context), useFocus(context), useDismiss(context), useRole(context, { role: "tooltip" }),
]);
return (
<>
<button ref={refs.setReference} {...getReferenceProps()}>{children}</button>
{open && (
<FloatingPortal>
<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>{label}</div>
</FloatingPortal>
)}
</>
);
}
// Toasts: render <Toaster /> once near the root, then call toast() from anywhere.
export function App() {
return (
<>
<Toaster position="bottom-right" />
<button onClick={() => toast.success("Saved")}>Save</button>
</>
);
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Toast timers that restart on every render
Start the toast's timer with useEffect(() => { start(); return () => clearTimeout(timer.current); }) (no dependency array).
Break #2
A modal div without focus management
Replace <dialog> with a div styled as a modal and remove the effect.
Myth vs fact
Myth
A higher z-index always wins.
Fact
z-index only competes inside the same stacking context. An element in a context with z-index: 1 can never appear above a sibling context with z-index: 2, whatever its own value. Portals and the top layer avoid the problem.
Myth
Portals break React events and context.
Fact
A portal's children stay in the React tree: context works, and React events bubble to React ancestors (even though the DOM is elsewhere).
Myth
Tooltips are fine on hover only.
Fact
Keyboard and touch users never hover. Show tooltips on focus too, dismiss on Escape, and never hide essential information in them.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Clicks on a
<dialog>'s padding also have the dialog ase.target, so the "backdrop click" check can close it unexpectedly. Put the content in an inner wrapper with the padding, or compare the click coordinates withgetBoundingClientRect(). Newer browsers supportclosedby="any"on<dialog>for light dismiss. - ▸
Since React 19,
<dialog onClose>andonCancelare supported events, and thepopover,popoverTargetandpopoverTargetActionattributes are recognised.popover="manual"disables light dismiss;popover="hint"(newer) is designed for tooltips that shouldn't close other popovers. - ▸
Toast libraries keep their state outside React (a small store with subscribe) so
toast()can be called from anywhere, even outside components, like after a fetch in a utility function. Sonner and react-hot-toast both work this way. - ▸
Animating exit needs the element to stay mounted until the animation ends. Libraries like Motion (
AnimatePresence) keep removed children rendered while they animate out; with CSS only you can use@starting-styleandtransition-behavior: allow-discretefor dialogs and popovers.
Remember this
- 1
Stacking and clipping. An overlay rendered deep inside your component tree inherits its ancestors' problems: a parent with
overflow: hiddencuts it off, and a parent that creates a stacking context (anything withtransform,opacity < 1,filter,positionplusz-index, and more) traps itsz-index, soz-index: 9999still loses to a sibling section. Two escapes: a portal (createPortal(child, document.body), Topic 8.5) renders the DOM at the end of<body>while the component stays in its React position (context and events still flow through the React tree), or the browser's top layer, which<dialog>.showModal()and thepopoverattribute use: it sits above everything, whatever thez-index. - 2
**Modals: use
<dialog>.**dialog.showModal()puts the dialog in the top layer, draws a::backdrop, makes the rest of the page inert (not clickable, not focusable, hidden from screen readers), moves focus into the dialog (to anautofocuselement or the first focusable one), closes on Escape (firingcancel, thenclose), and onclose()returns focus to the element that was focused before. In React, keep anopenstate, callshowModal()/close()in an effect, and handleonCloseso Escape updates your state. Label it witharia-labelledby. Hand-built modals (adivin a portal) need all of that by hand: a focus trap (Tab and Shift+Tab cycle inside),aria-modal="true",inerton the rest of the app, Escape handling and focus return (Phase 18). Scroll locking is separate (Topic 24.3). - 3
Toasts. Short, non-blocking messages ("Saved", "Message sent"). A provider holds a queue in state and exposes
toast(text)through context; a portal renders the visible ones in a fixed corner. Show at most a few at once and keep the rest queued. Each toast auto-dismisses after a few seconds, but pauses while hovered or focused (so people can read it or reach its button) and resumes with the remaining time. Put them in arole="status"(polite live region) so screen readers announce them; userole="alert"only for urgent errors. Never put the only way to do something (like Undo for a delete) in a toast that disappears before a keyboard or screen-reader user can reach it; WCAG asks for enough time. - 4
Tooltips and positioning. A tooltip is a short text label for a control, shown on hover and on keyboard focus (never hover only), hidden on Escape and blur, linked with
aria-describedbyandrole="tooltip". It must never contain interactive content (use a popover for that). Positioning: measure the trigger withgetBoundingClientRect()inuseLayoutEffect(before paint), place the tip above it, and flip below if it would go off-screen; also shift it sideways to stay inside the viewport, and update on scroll and resize. Floating UI does exactly this (itsflip,shiftandoffsetmiddleware). New CSS anchor positioning (anchor-name,position-anchor,position-try) can do it without JavaScript in browsers that support it. - 5
Menus and popovers. A menu button opens a list of actions. The HTML
popoverattribute gives you the top layer, light dismiss (click outside or press Escape closes it) and focus handling with no JavaScript:<button popoverTarget="menu">toggles<div id="menu" popover="auto">. React 19 supports these attributes; in code useshowPopover(),hidePopover()and thetoggleevent. For a full ARIA menu (role="menu", arrow-key navigation, typeahead) a library such as Radix UI or React Aria saves a lot of work; often a simple popover containing ordinary buttons is more accessible than a half-builtrole="menu".
Explain it without notes
Why do overlays need portals or the top layer?
What does <dialog>.showModal() give you, and how do you use it from React?
Design a toast system: API, queueing, timing and accessibility.
How do you position a tooltip so it stays on screen?
Practice
Add a type ("success" or "error") to the toast system; errors use role="alert" and don't auto-dismiss.
Make the tooltip also shift sideways so it never goes past the right edge of the window.
Trade-offs
- ↔
Native
<dialog>andpopovergive correct focus, layering and dismissal with almost no code, but styling animations and some behaviours (backdrop click) need care; libraries like Radix and React Aria add consistent behaviour across browsers at the cost of a dependency. - ↔
Toasts are unobtrusive but easy to miss and time-limited; for important or actionable messages, prefer inline messages or a dialog.
Done when you can
I can explain stacking contexts and escape them with portals or the top layer.
I can drive a native dialog from React state with Escape and focus return working.
I can build a toast queue with timeouts that pause on hover and focus.
I can position a tooltip next to its trigger and flip it when there's no room.
I can use the popover attribute for a simple menu.