Layer
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/layer.jsonIt 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.
What it solves
Section titled “What it solves”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.
usePopupContainer()
Section titled “usePopupContainer()”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.
layerFloor(rung)
Section titled “layerFloor(rung)”The CSS expression a layer’s floor is declared with, for a product that needs to place something of its own on it.
popupRoom
Section titled “popupRoom”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.
| Prop | Type | Default | |
|---|---|---|---|
aboverequired | menu | popup | floating | overlay | modal | palette | The 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. | |
mount | RefObject<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. | |
childrenrequired | ReactNode |
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.