Skip to content

Systematize z-index usage across the site #12363

Description

@lokesh

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)

  • isolation: isolate + local tokens: search-result cover fan, list-card cover stacks, rating stars, mybooks-details, editionCover animation, carousel, segmented control
  • Verify slick.css translate3d is contained by carousel isolation

Phase 2 — promotion (feature-detected, fallback = current behavior)

  • ol-popoverpopover="auto" (Select/Menu/Options popovers inherit for free)
  • ol-tooltippopover="manual"
  • ol-toast-regionpopover="manual" + re-promote on each toast (fixes toast-behind-modal)
  • Replace plot.js hardcoded z-index: 100

Phase 3 — compression (only after Phase 2)

  • Renumber primitives to the 1–5 ladder above; retarget semantic tokens
  • Mark dropdown/modal/overlay/toast tokens as legacy-only aliases
  • Attempt removal of !important in ui-dialog.css / legacy-jquery-ui.css

Phase 4 — enforcement

  • Extend Stylelint to .vue (postcss-html) and Lit css literals (postcss-lit) — today neither is linted, which is how raw values keep returning
  • Forbid raw z-index numbers and level-* primitives outside the token file
  • Rewrite the token docblock around the three layering kinds + the top-layer promotion convention

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Affects: UIIssues with the web site's user interface. [managed]Lead: @lokeshIssues pertaining to front-end design system [css, js, components]Needs: ResponseIssues which require feedback from leadTeam: Front-endIssues belonging to the Front-end team [experimental tag]Theme: CSSIssues related to CSS stylesheets. [managed]Type: Refactor/Clean-upIssues related to reorganization/clean-up of data or code (e.g. for maintainability). [managed]

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions