Back to Langfuse

Overlay Layers

web/src/components/design-system/OverlayLayers.mdx

3.203.33.9 KB
Original Source

import { Meta } from "@storybook/addon-docs/blocks";

<Meta title="Design System/Overlay Layers" parameters={{ layout: "fullscreen" }} />

Overlay Layers

How every overlay in the app stacks: by structure (DOM order), not by z-index.

The problem this solves

Overlays such as dialogs, dropdowns, tooltips, toasts, and the in-app assistant used to portal to <body> and compete for “who is on top” with hand-picked z-index values. Those values kept escalating from z-50 to z-51 to z-60 to z-[9999], and whoever picked the bigger one won until the next overlay reset the race. Real bugs followed: toasts hidden behind the trace peek, the search-bar error tooltip clipped, and the nav dropdown colliding with the assistant window.

The model

The whole app renders inside #__next, which is its own isolated stacking context via isolation: isolate. That caps every z-index used inside the app, so nothing in-app can paint over an overlay. The overlay layer containers are declared once in _document.tsx as <body> siblings after #__next, so they paint on top purely by DOM order, where later means on top, and each is itself an isolated stacking context.

Ordering is the layer's job. Overlays carry no z-index.

The layers (low to high)

<table> <thead> <tr> <th>Layer</th> <th>Holds</th> <th>Why it sits here</th> </tr> </thead> <tbody> <tr> <td><code>agent</code></td> <td>the in-app assistant window</td> <td> persistent and draggable; floats above the page but below every transient overlay </td> </tr> <tr> <td><code>modal</code></td> <td> <code>Dialog</code>, <code>AlertDialog</code>, <code>Sheet</code>{" "} including the table peek, <code>Drawer</code> </td> <td>blocking surfaces, above the page and the assistant</td> </tr> <tr> <td><code>popover</code></td> <td> <code>Popover</code>, <code>DropdownMenu</code>, <code>Select</code>,{" "} <code>HoverCard</code> </td> <td> above modal, so a <code>Select</code> or <code>Popover</code> opened inside a dialog still renders on top </td> </tr> <tr> <td><code>tooltip</code></td> <td><code>Tooltip</code> and bespoke anchored tooltips</td> <td>hints stay above their trigger, even inside a modal</td> </tr> <tr> <td><code>toast</code></td> <td>Sonner toasts</td> <td>last, so they always sit above everything by order alone</td> </tr> </tbody> </table>

LAYER_ORDER in components/ui/layer.tsx is the source of truth; _document.tsx maps it to the containers.

Pointer events

The layer containers are pointer-events: none, so the empty space around a non-modal overlay, like the table peek, stays click-through to the app behind it. Each portaled overlay opts itself back in with one global rule, [data-overlay-root] > [data-layer] > * { pointer-events: auto; }, so every overlay is interactive by construction, modal or not.

How to use it

  • Radix or Vaul primitive: route its *.Portal into a layer with useLayerContainer(name). The ui/* wrappers such as Dialog, DropdownMenu, and Select already do this, so most code gets it for free.
  • Bespoke positioned content: render it through <Layer name="…">.
  • Never give an overlay a z-index to escape, and never let a *.Portal fall back to <body>.

The guardrail

The @repo/no-overlay-zindex ESLint rule fails the build if a new overlay reaches for a z-index escape, so the category of bug cannot return. Legit in-app chrome such as sticky page headers, fixed top banners, and the bulk action bar keeps its z-index because it lives inside #__next and never competes with overlays.

Part of the design system. Storybook is, step by step, becoming the home for these design-system decisions.