Skip to content

Layer

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/layer.json

It lands in components/ui/layer.tsx. Every overlay and every popup of the set imports it, so it arrives with the first of them you install - there is nothing to wire by hand.

See it liveA select in a dialog, a select in a popover, and a popover in a dialog with a select and a tooltip in it - each opening above what opened it.→

The stacking scale is a flat ladder, and every popup portals to the document body, so the ladder alone decides who covers whom. It has nothing to say about a popup opened inside an overlay: a date picker in a dialog is a 40 beside a 60, and the month draws under the dialog that asked for it.

Two fixes look right and are not. Portalling the popup into the overlay puts it inside a box that scrolls, and a scroll box clips it - a month with one row of days showing. Raising the popup’s own number works exactly once: it is then above every overlay, including the ones it is not in.

So the popup stays where nothing clips it and the layer travels. An overlay opens a host inside its own portal and raises the popup rungs there; a popup opened anywhere within the overlay portals into that host and reads the raised value through ordinary CSS inheritance.

The set’s overlays - Dialog, Drawer, ConfirmDialog, Popover, CommandPalette - open a layer for their content, and its popups - Select, Combobox, Menu, ContextMenu, Popover, PreviewCard, Tooltip - portal into the layer they were opened in. A product writes nothing for them.

A product’s own overlay does the same, with the rung it stands on:

function Stage({ children }: { children: ReactNode }) {
return (
<div className="fixed inset-0 bg-bg [z-index:var(--z-overlay)]">
<LayerProvider above="overlay">{children}</LayerProvider>
</div>
)
}

A product’s own popup takes the host the same way the set’s do:

const host = usePopupContainer()
return <Base.Portal container={container ?? host}>…</Base.Portal>

The floor is worked out by the stylesheet. A layer’s floor is calc(max(var(--z-modal), var(--z-layer, 0)) + 1) - read from the theme where the ladder is declared. Renumbering the ladder moves every floor with it, and there is no second copy of the numbers to drift.

A layer inside a layer keeps climbing. A popover opened in a dialog is a layer of its own, so a select inside it clears the popover and not only the dialog: 60 for the dialog, 61 for the popover, 62 for the select.

The layer lives inside the overlay’s portal, and that is for a reader, not for the stacking. A modal marks everything outside its portal aria-hidden, and a layer beside the portal was hidden with the page: the menu in a drawer worked for a pointer and did not exist for a screen reader. Inside the portal, the popups count as the overlay’s own. An overlay passes its portal as mount; without it the layer goes into the layer it was opened in, or onto the body.

Two nodes, not one. The floor is computed from the rung the host inherits, and the host then redefines that rung - on one element that is a cycle, which CSS answers by making both values invalid. An outer frame works the floor out and an inner host publishes it. Neither draws anything or makes a stacking context.

Where a popup opened here should portal to: the host of the layer it is in, or undefined outside every overlay - which a portal reads as the body.

The CSS expression a layer’s floor is declared with, for a product that needs to place something of its own on it.

The classes that hold a popup placed against a trigger to the room its positioner publishes, on both axes, and scroll it inside itself past that: max-h-[var(--available-height)] max-w-[var(--available-width)] overflow-y-auto overscroll-contain. Menu, Popover, PreviewCard and Tooltip wear it; a product’s own anchored popup should too.

Props

Read from the types in ui/layer.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

Opens a raised host beside an overlay and offers it to every popup opened inside. Put it inside the overlay's popup, around its content.

PropTypeDefault
aboverequiredmenu | popup | floating | overlay | modal | paletteThe rung the overlay itself stands on: modal for a dialog or a drawer, palette for the command palette, floating for a popover, overlay for a product's own full-screen stage.
mountRefObject<HTMLElement | null>Where the layer goes: the overlay's own portal node. Every overlay of the set passes it, and it matters for the modal ones. A modal dialog hides everything outside its portal from assistive technology - Base UI marks the rest of the body aria-hidden - and a layer beside the portal rather than in it was hidden with the page: the menu opened in a drawer worked for a pointer and did not exist for a screen reader. Inside the portal the layer counts as the overlay's own. Without it the layer goes into the layer it was opened in, or onto the body.
childrenrequiredReactNode

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 12 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/layer.tsx - the file a product copies - newest first.

  1. v0.40.0
    • fix hold every surface over the page to its room on both axes d0436a7
  2. v0.32.0breaking
    • fix make the host once without reading a ref during render eab2935
    • feat a layer for popups inside overlays, a body that scrolls alone 5244cfd