Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~30 min· topic 8 of 10

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-index values only compete with each other, created by things like transform or opacity.
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.

Get out of the parent's boxdiagram
Rendering diagram…

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).

Drive the native dialog from React statediagram
Rendering diagram…

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.

position.jswhole filejavascript
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 inside

05Use 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. 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 says open is true, so the effect doesn't run again.

  2. 2

    Change the queue size

    Set MAX_VISIBLE to 5 and predict the three log lines. Then set DURATION to 2000 and check that hovering is no longer needed to keep the first toast.

  3. 3

    Put the tooltip inside a clipped box

    Render the tooltip without createPortal inside a div with overflow: hidden; transform: translateZ(0). Hover it: the tip is cut off, because transform makes the box the containing block for position: fixed children.

Code & diagrams

A modal built on the native dialog New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Open: modal true, focus on Cancel
After Escape: open false, focus back on opener
A toast queue: portal, max 3, auto-dismiss and pause on hover React 19+ New tab

Five 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

Press Run to see the page.

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: none
A tooltip that flips, and a native popover menu New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Edit tooltip placed on bottom
Share tooltip placed on top
Menu open: true
Floating UI and Sonner (not live: install @floating-ui/react and sonner)jsx
import { 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).

terminal
$ Preview
── what you'll see ──
Toasts stay on screen much longer than DURATION, or never leave while other toasts keep arriving.

Break #2

A modal div without focus management

Replace <dialog> with a div styled as a modal and remove the effect.

terminal
$ Keyboard test
── what you'll see ──
Tab moves focus to links behind the backdrop, screen readers read the page underneath, Escape does nothing, and after closing, focus is lost at the top of the page.

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 as e.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 with getBoundingClientRect(). Newer browsers support closedby="any" on <dialog> for light dismiss.

  • ▸

    Since React 19, <dialog onClose> and onCancel are supported events, and the popover, popoverTarget and popoverTargetAction attributes 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-style and transition-behavior: allow-discrete for dialogs and popovers.

Remember this

  1. 1

    Stacking and clipping. An overlay rendered deep inside your component tree inherits its ancestors' problems: a parent with overflow: hidden cuts it off, and a parent that creates a stacking context (anything with transform, opacity < 1, filter, position plus z-index, and more) traps its z-index, so z-index: 9999 still 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 the popover attribute use: it sits above everything, whatever the z-index.

  2. 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 an autofocus element or the first focusable one), closes on Escape (firing cancel, then close), and on close() returns focus to the element that was focused before. In React, keep an open state, call showModal()/close() in an effect, and handle onClose so Escape updates your state. Label it with aria-labelledby. Hand-built modals (a div in a portal) need all of that by hand: a focus trap (Tab and Shift+Tab cycle inside), aria-modal="true", inert on the rest of the app, Escape handling and focus return (Phase 18). Scroll locking is separate (Topic 24.3).

  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 a role="status" (polite live region) so screen readers announce them; use role="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. 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-describedby and role="tooltip". It must never contain interactive content (use a popover for that). Positioning: measure the trigger with getBoundingClientRect() in useLayoutEffect (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 (its flip, shift and offset middleware). New CSS anchor positioning (anchor-name, position-anchor, position-try) can do it without JavaScript in browsers that support it.

  5. 5

    Menus and popovers. A menu button opens a list of actions. The HTML popover attribute 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 use showPopover(), hidePopover() and the toggle event. 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-built role="menu".

Explain it without notes

01

Why do overlays need portals or the top layer?

02

What does <dialog>.showModal() give you, and how do you use it from React?

03

Design a toast system: API, queueing, timing and accessibility.

04

How do you position a tooltip so it stays on screen?

Practice

01

Add a type ("success" or "error") to the toast system; errors use role="alert" and don't auto-dismiss.

02

Make the tooltip also shift sideways so it never goes past the right edge of the window.

Trade-offs

  • ↔

    Native <dialog> and popover give 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.