Updated 2026-08-03. Rewritten after a deeper investigation of how our overlays actually behave (see "What we verified" below). The original goals stand, but the strategy is now built around three kinds of layering instead of one global z-index scale. Original text is in the edit history.
The problem, in plain terms
When two things on a web page overlap, the browser has to decide which one draws on top. z-index is the CSS property for that: a number, where bigger wins.
The catch is that z-index numbers only compete within the same "stacking context" — think of stacking contexts as buildings, and z-index as floor numbers. Floor 999 of a small building is not above floor 2 of the building next door; which building is in front is decided separately, by the buildings' own ordering. Lots of everyday CSS quietly creates a new building (position: sticky + z-index, transform, opacity < 1, filter…). So a menu inside a card can set z-index: 99999 and still be stuck behind a site header at z-index: 3, because it's trapped inside its building.
This is why our z-index values escalated over the years (999 → 9999 → 99999 → 999999): when something rendered behind something else, developers reached for a bigger number. Sometimes it worked (same building), sometimes it couldn't ever work (different buildings) — and either way the numbers grew.
There's one more piece: browsers now have a "top layer" — a special layer above every building on the page. Elements get into it by using native APIs (<dialog>.showModal(), the popover attribute), not by any z-index value. Nothing on the page can cover a top-layer element, and no ancestor can trap it.
What we verified (Aug 2026)
- Our modal dialog already does the right thing.
ol-dialog (and the search modal built on it) uses native showModal(), sits in the top layer, and contains zero z-index declarations. It can't be trapped or out-stacked by anything. This is the model for all overlays.
- Our biggest z-index token loses anyway.
--z-index-toast: 999999 is documented as "always on top," but a toast fired before a dialog opens renders dimmed behind the dialog's backdrop and can't be clicked. 999999 is the top of our ladder — and the entire ladder sits beneath the top layer. No number fixes this.
- Popovers and tooltips are one
transform away from breaking. ol-popover and ol-tooltip use position: fixed + z-index. It works today by luck of DOM order; any transformed ancestor (e.g. a carousel track) clips them. We already hit exactly this bug with tooltips in carousels.
- The
--z-index-overlay: 99999 tier ("autocomplete above a dialog") is a fossil. In the top layer, a popover opened from inside a dialog paints above it automatically. That whole tier exists only because our overlays live on the page.
- Components burn global tokens for private layering. A search-result cover fan uses five site-wide z-index levels just to fan five covers. That ordering is meaningless outside the component and shouldn't leak.
Proposal: three kinds of layering, three tools
1. Overlays (dialogs, popovers, menus, tooltips, toasts) → top layer, no z-index.
Use showModal() for modals (done) and the popover attribute for everything else, feature-detected with today's fixed+z-index code as the fallback for older Safari. Ordering within the top layer is by promotion order (last shown is on top), not numbers — the toast region re-promotes itself when a toast arrives so toasts stay visible above open dialogs. Bonus: popovers get a native ::backdrop and built-in Escape/light-dismiss ordering, deleting hand-rolled code.
2. Page chrome (sticky headers, nav bars, sticky table headers, ILE toolbar) → a small ladder that ends at 5.
Once overlays leave the page, the ladder's only job is ordering chrome, and it compresses to contiguous integers:
| token |
value |
used for |
--z-behind |
-1 |
decorative elements behind their parent |
--z-base |
1 |
minimum lift above static flow |
--z-raised |
2 |
one step above base siblings |
--z-content-sticky |
3 |
sticky chrome inside content: table headers, ILE toolbar |
--z-site-sticky |
4 |
sticky site header |
--z-site-fixed |
5 |
compact-title, nav-bar-wrapper |
One rule replaces the arms race: if you think you need a number above 5, you're building an overlay — promote it to the top layer instead of outbidding. (This also finally gives sticky table headers a correct home: content-sticky, below site chrome. The old scale had no right answer, which is why MergeUI picked 300.)
3. Component internals (cover fans, carousel arrows, star ratings) → isolation: isolate + local values.
isolation: isolate on a component's root makes its internal z-indexes invisible to the rest of the page — the component becomes its own sealed building. Inside, use a tiny local tier (--z-local-1/2/3). Internal ordering stops consuming (and colliding with) the global vocabulary.
Legacy overlays we can't easily migrate (jQuery UI dialogs, colorbox) keep their current frozen values; no new consumers.
Plan
Phase 0 — in flight
Phase 1 — containment (zero visual risk, do first)
Phase 2 — promotion (feature-detected, fallback = current behavior)
Phase 3 — compression (only after Phase 2)
Phase 4 — enforcement
Success criteria (revised to be checkable)
- New overlay components ship with no z-index at all (like
ol-dialog today)
- A toast fired while a dialog is open is visible above it
- Highest z-index value in non-legacy code: 5
- Every component with internal layering is isolated; internal values never appear in global tokens
- Stylelint covers CSS, Vue, and Lit, and rejects raw z-index numbers
Known tradeoff
A toast promoted above a modal is visible but not clickable while the modal is open (modal dialogs make the rest of the page inert — that's what makes them modal). For auto-dismissing status toasts, visibility is the whole requirement; persistent toasts become dismissable when the modal closes. If that's ever not acceptable we can route toasts fired during a modal into the dialog itself, but that's machinery for a rare case.
Browser support note
The Popover API needs ~Safari 17 / Chrome 114 / Firefox 125. Our component floor is lower (~Safari 15.4), so promotion ships as progressive enhancement: feature-detect HTMLElement.prototype.showPopover, else keep the current fixed+z-index path. The fallback code already exists — it's today's implementation.
Stakeholders
- @cdrini — front-end component architecture, popover/drawer work
- @lokesh — design system lead
The problem, in plain terms
When two things on a web page overlap, the browser has to decide which one draws on top.
z-indexis the CSS property for that: a number, where bigger wins.The catch is that z-index numbers only compete within the same "stacking context" — think of stacking contexts as buildings, and z-index as floor numbers. Floor 999 of a small building is not above floor 2 of the building next door; which building is in front is decided separately, by the buildings' own ordering. Lots of everyday CSS quietly creates a new building (
position: sticky+ z-index,transform,opacity < 1,filter…). So a menu inside a card can setz-index: 99999and still be stuck behind a site header atz-index: 3, because it's trapped inside its building.This is why our z-index values escalated over the years (
999 → 9999 → 99999 → 999999): when something rendered behind something else, developers reached for a bigger number. Sometimes it worked (same building), sometimes it couldn't ever work (different buildings) — and either way the numbers grew.There's one more piece: browsers now have a "top layer" — a special layer above every building on the page. Elements get into it by using native APIs (
<dialog>.showModal(), thepopoverattribute), not by any z-index value. Nothing on the page can cover a top-layer element, and no ancestor can trap it.What we verified (Aug 2026)
ol-dialog(and the search modal built on it) uses nativeshowModal(), sits in the top layer, and contains zero z-index declarations. It can't be trapped or out-stacked by anything. This is the model for all overlays.--z-index-toast: 999999is documented as "always on top," but a toast fired before a dialog opens renders dimmed behind the dialog's backdrop and can't be clicked. 999999 is the top of our ladder — and the entire ladder sits beneath the top layer. No number fixes this.transformaway from breaking.ol-popoverandol-tooltipuseposition: fixed+ z-index. It works today by luck of DOM order; any transformed ancestor (e.g. a carousel track) clips them. We already hit exactly this bug with tooltips in carousels.--z-index-overlay: 99999tier ("autocomplete above a dialog") is a fossil. In the top layer, a popover opened from inside a dialog paints above it automatically. That whole tier exists only because our overlays live on the page.Proposal: three kinds of layering, three tools
1. Overlays (dialogs, popovers, menus, tooltips, toasts) → top layer, no z-index.
Use
showModal()for modals (done) and thepopoverattribute for everything else, feature-detected with today's fixed+z-index code as the fallback for older Safari. Ordering within the top layer is by promotion order (last shown is on top), not numbers — the toast region re-promotes itself when a toast arrives so toasts stay visible above open dialogs. Bonus: popovers get a native::backdropand built-in Escape/light-dismiss ordering, deleting hand-rolled code.2. Page chrome (sticky headers, nav bars, sticky table headers, ILE toolbar) → a small ladder that ends at 5.
Once overlays leave the page, the ladder's only job is ordering chrome, and it compresses to contiguous integers:
--z-behind--z-base--z-raised--z-content-sticky--z-site-sticky--z-site-fixedOne rule replaces the arms race: if you think you need a number above 5, you're building an overlay — promote it to the top layer instead of outbidding. (This also finally gives sticky table headers a correct home:
content-sticky, below site chrome. The old scale had no right answer, which is why MergeUI picked 300.)3. Component internals (cover fans, carousel arrows, star ratings) →
isolation: isolate+ local values.isolation: isolateon a component's root makes its internal z-indexes invisible to the rest of the page — the component becomes its own sealed building. Inside, use a tiny local tier (--z-local-1/2/3). Internal ordering stops consuming (and colliding with) the global vocabulary.Legacy overlays we can't easily migrate (jQuery UI dialogs, colorbox) keep their current frozen values; no new consumers.
Plan
Phase 0 — in flight
Phase 1 — containment (zero visual risk, do first)
isolation: isolate+ local tokens: search-result cover fan, list-card cover stacks, rating stars, mybooks-details, editionCover animation, carousel, segmented controltranslate3dis contained by carousel isolationPhase 2 — promotion (feature-detected, fallback = current behavior)
ol-popover→popover="auto"(Select/Menu/Options popovers inherit for free)ol-tooltip→popover="manual"ol-toast-region→popover="manual"+ re-promote on each toast (fixes toast-behind-modal)plot.jshardcoded z-index: 100Phase 3 — compression (only after Phase 2)
dropdown/modal/overlay/toasttokens as legacy-only aliases!importantin ui-dialog.css / legacy-jquery-ui.cssPhase 4 — enforcement
.vue(postcss-html) and Litcssliterals (postcss-lit) — today neither is linted, which is how raw values keep returninglevel-*primitives outside the token fileSuccess criteria (revised to be checkable)
ol-dialogtoday)Known tradeoff
A toast promoted above a modal is visible but not clickable while the modal is open (modal dialogs make the rest of the page inert — that's what makes them modal). For auto-dismissing status toasts, visibility is the whole requirement; persistent toasts become dismissable when the modal closes. If that's ever not acceptable we can route toasts fired during a modal into the dialog itself, but that's machinery for a rare case.
Browser support note
The Popover API needs ~Safari 17 / Chrome 114 / Firefox 125. Our component floor is lower (~Safari 15.4), so promotion ships as progressive enhancement: feature-detect
HTMLElement.prototype.showPopover, else keep the current fixed+z-index path. The fallback code already exists — it's today's implementation.Stakeholders