Z-Index Strategy
Semantic z-index tokens, single-source-of-truth codegen, global vs local tiers, and a forbid-raw-z-index lint rule.
The Problem
A site grows past five overlay types β modal, drawer, popover, toolbar, tooltip β and z-index becomes a hand-rolled lottery. Every new overlay nudges a value up: 10, 20, 99, 100, 9999. Within a year the codebase contains z-index: 1, z-index: 21, z-index: 100, z-index: 99999, and a comment that reads / with no way to verify the claim.
The standard symptoms:
- Magic numbers everywhere. A grep for
z-index:returns dozens of unique integers with no shared scale. - The
99999reflex. When a layer renders behind another, the fix is βmake my number biggerβ β no investigation of the actual stacking context. - Renumbering paralysis. Adding a new tier between
100(modal) and200(toast) means picking150and praying nothing in the codebase already uses it. - Tailwindβs
z-10/z-20/z-30scale. This was a workaround for the renumbering pain β leave gaps so you can squeeze new layers between old ones. It traded one numeric scale for another and demanded that every developer memorise which class corresponds to which overlay role.
The renumbering pain and the memorisation tax both dissolve in the AI-era refactor: a model can rename --z-200 β --z-toast across hundreds of files in seconds. The cost that drove numeric scales no longer exists.
The Solution
Define z-index tiers as semantic, single-namespace tokens generated from a single TypeScript source of truth.
:root {
/* Generated from z-index-tokens.ts β do not hand-edit */
--z-content: 0;
--z-toolbar: 10;
--z-dropdown: 20;
--z-popover: 30;
--z-popover-portaled: 40;
--z-modal-backdrop: 50;
--z-modal: 60;
--z-toast: 70;
--z-tooltip: 80;
}
Every component CSS file uses the named token, never a raw integer:
.modal {
position: fixed;
z-index: var(--z-modal);
}
.toast {
position: fixed;
z-index: var(--z-toast);
}
Three properties make this work:
- Semantic names β
--z-modaltells you what layer it is.--z-200does not. - Single namespace β one flat list of tiers, not split by feature or context. (Z-index is fundamentally one ordering problem, unlike density tokens which split per design context.)
- Codegen from a TS source of truth β the CSS block, the styleguide table, and any TypeScript constants all derive from one file. Drift is structurally impossible.
Why not Tailwind-numeric?
Tailwindβs default z-0 / z-10 / z-20 / z-30 / z-40 / z-50 scale exists for one reason: in pre-AI CSS work, renumbering tokens across a codebase was painful. Leaving gaps (10, 20, 30β¦) let you squeeze new tiers between old ones without touching existing code.
| Era cost | Pre-AI (manual refactor) | AI-era refactor |
|---|---|---|
Renaming --z-200 β --z-toast across 300 files | Hours of find-and-replace, easy to break | Seconds, structural rename |
| Inserting a tier between two existing values | Painful β may force renumbering downstream | Trivial β regenerate the scale, AI updates references |
Memorising βwhat does z-30 mean in our app?β | Required onboarding doc | Eliminated β names are self-describing |
Both costs that drove numeric scales β refactor pain and memorisation tax β collapse once a model can confidently rename across the whole codebase. Semantic names win on every other axis: readability, grepability, code review, onboarding.
π‘ Migration path
If a project is already on Tailwind-numeric (z-10/z-20/β¦), migrate by mapping each numeric utility to a semantic token, then doing a single AI-assisted rename pass. Do not migrate one component at a time β half-migrated codebases are harder to reason about than either pure state.
Single-namespace vs multi-namespace
For density tokens (spacing, font sizes), splitting into multiple namespaces β --spacing-myweb-* and --spacing-myadmin-* β is the right call. Article pages and admin dashboards genuinely need different scales. See Multi Namespace Token Strategy.
Z-index does not follow that rule. There is exactly one viewport, one stacking universe, and one ordering question: βis this above or below that?β. A modal in the article context still needs to render above a tooltip in the admin context if both happen to be on screen during a flow.
| Token category | Namespace | Reason |
|---|---|---|
| Spacing, font sizes | Multi | Different design contexts have different density |
| Colors, font families | Single (shared) | Brand identity is global |
| Z-index | Single (global) | One ordering universe per app |
Keep z-index in a single namespace. Resist the urge to define --z-myweb-modal separate from --z-myadmin-modal β they would always need to be kept in sync, which is exactly what a single token already does.
Source of truth: TS β codegen β CSS
Define tiers in TypeScript. Generate the CSS block. Have the styleguide read the TS file directly.
// src/styles/z-index-tokens.ts
export type ZIndexTier = {
name: string; // CSS var name without --z- prefix
value: number; // numeric z-index
purpose: string; // human-readable role
kind: "global" | "local";
};
export const Z_INDEX_TIERS: ZIndexTier[] = [
{ name: "content", value: 0, purpose: "Default in-flow content", kind: "global" },
{ name: "toolbar", value: 10, purpose: "Sticky toolbars and headers", kind: "global" },
{ name: "dropdown", value: 20, purpose: "In-flow dropdown menus", kind: "global" },
{ name: "popover", value: 30, purpose: "Inline popovers (not portaled)", kind: "global" },
{ name: "popover-portaled", value: 40, purpose: "Popovers rendered via portal", kind: "global" },
{ name: "modal-backdrop", value: 50, purpose: "Modal/drawer backdrop", kind: "global" },
{ name: "modal", value: 60, purpose: "Modal/drawer foreground", kind: "global" },
{ name: "toast", value: 70, purpose: "Transient notifications", kind: "global" },
{ name: "tooltip", value: 80, purpose: "Tooltips (highest UI layer)", kind: "global" },
{ name: "local-1", value: 1, purpose: "Child promotion within parent", kind: "local" },
{ name: "local-2", value: 2, purpose: "Child promotion within parent", kind: "local" },
{ name: "local-3", value: 3, purpose: "Child promotion within parent", kind: "local" },
];
The codegen rewrites a marker block inside the main CSS file:
/* GENERATED:Z_INDEX_BEGIN β do not hand-edit; run `pnpm gen:z-index` */
:root {
--z-content: 0;
--z-toolbar: 10;
--z-dropdown: 20;
--z-popover: 30;
--z-popover-portaled: 40;
--z-modal-backdrop: 50;
--z-modal: 60;
--z-toast: 70;
--z-tooltip: 80;
--z-local-1: 1;
--z-local-2: 2;
--z-local-3: 3;
}
/* GENERATED:Z_INDEX_END */
Wire two scripts:
| Script | Purpose | When to run |
|---|---|---|
pnpm gen:z-index | Rewrites the GENERATED:Z_INDEX_* marker block in App.css from Z_INDEX_TIERS | After editing z-index-tokens.ts |
pnpm check:z-index | Re-runs codegen to a temp file and diffs against the committed CSS β non-zero exit on drift | Pre-push hook, CI |
The styleguide page imports Z_INDEX_TIERS directly from the TS file and renders a table. The table can never go stale because there is no second copy of the data.
π Why a marker block, not a separate CSS file
Putting the generated CSS inside App.css between markers means the rest of App.css stays hand-written. A separate tokens-z-index.css would also work, but markers keep the file count down and make it obvious to a reader of App.css that this block is generated.
Global vs local tokens
Once the global tier list is in place, a recurring pattern emerges in components: z-index: 1 on a child to lift it above siblings within a stacking-context-creating parent. These values are not independent tiers in the global ordering β they only matter relative to the parent.
For these cases, define a small reserved family: --z-local-1, --z-local-2, --z-local-3.
.card {
position: relative;
isolation: isolate; /* parent creates a stacking context */
}
.card__background {
position: absolute;
inset: 0;
z-index: var(--z-local-1); /* sits above default flow but inside .card */
}
.card__content {
position: relative;
z-index: var(--z-local-2); /* sits above .card__background */
}
The rule for using --z-local-N:
Use
--z-local-Nonly when (a) the parent creates a stacking context and (b) the child is being promoted above its siblings, not stacked against other components.
If the child needs to render above unrelated UI (e.g. a popover that must sit above the page toolbar), it is not local β it is a global tier and belongs in the named scale.
π βz-local-N is reusable
The same --z-local-1 token applies to every isolated parent. There is no need for --z-card-bg or --z-modal-content local tokens β local tiers are anonymous helpers, not semantic per-component values.
Lint enforcement
A token system that is not enforced is a token system that decays. Add a lint rule that forbids raw z-index: <integer> in component CSS.
| Allowed | Forbidden |
|---|---|
z-index: var(--z-modal); | z-index: 100; |
z-index: calc(var(--z-modal) + 1); | z-index: 999; |
z-index: auto; / inherit; / initial; | z-index: 9999; |
Raw integer with / escape hatch | Bare raw integer with no escape comment |
The escape hatch matters. Some legitimate cases (a one-off third-party widget integration, an experimental layer) need a raw integer with a code comment explaining why β block by default, but do not block forever.
For the full multi-pass linter pattern (raw color literals, zone-aware semantic tokens, and the matching enforcement rules for other token families), see Design Token Lint. Pass 3 covers the raw-z-index rule documented above.
βΉοΈ Pre-push wiring
Add both pnpm check:z-index (codegen drift) and the lint rule (raw z-index: integers) to a pre-push hook. The drift check catches stale generated CSS; the lint check catches new violations. Together they prevent the system from rotting between PRs.
Decision tree: where does my new overlay belong?
Is the new layer a stacking promotion *inside* a parent that has its own
stacking context (isolation: isolate, position+z-index, transform, etc.)?
β
βββ YES β use --z-local-1 / --z-local-2 / --z-local-3
β (No global tier needed. The parent's stacking context contains it.)
β
βββ NO β it is a global UI layer
β
βββ Does it already match a tier? (modal, toast, tooltip, popover, ...)
β β
β βββ YES β use that tier's token
β β
β βββ NO β add a new tier to z-index-tokens.ts, run pnpm gen:z-index,
β then use the new token. Update the styleguide automatically.
β
βββ If you find yourself adding more than 1-2 tiers per quarter, the tier
list is probably too granular β review whether existing tiers cover it.
Portal vs inline decision
A popover anchored to a button can render either inline (in the buttonβs DOM subtree) or portaled (appended to <body>). The choice changes which token applies.
| Situation | Render mode | Token |
|---|---|---|
Anchor element has no stacking-context ancestor between it and <html> | Inline | --z-popover |
| Anchor element is inside a stacking context (modal, isolated card, transform parent) and the popover must escape | Portaled to <body> | --z-popover-portaled |
| Popover must always sit above a modal | Portaled | --z-popover-portaled (placed above --z-modal in the scale) |
Walk up the DOM from the anchor to the root; if any ancestor creates a stacking context, the inline popover will be trapped inside it. See Stacking Context for the full list of properties that create one.
Common AI Mistakes
- Defining a numeric scale (
--z-50,--z-100,--z-200). This recreates the Tailwind-numeric problem with custom property syntax. Names should describe roles, not magnitudes. - Hardcoding
z-index: 100βbecause biggerβ. The fix to a layering bug is almost never a higher number β it is understanding which stacking context the element lives in. See Stacking Context. - Using
--z-local-Noutside a stacking context. Without an isolated parent,--z-local-1participates in the root stacking context and competes against global tiers. Either addisolation: isolateto the parent, or use a global tier. - Defining
--z-emergency: 99999. The βemergencyβ tier is the bug, not the fix. If a layer needs to escape its parent, fix the stacking context. If a real new tier is needed, add it to the named scale at the right position. - Splitting z-index into per-context namespaces (
--z-myweb-modal,--z-myadmin-modal). The viewport is one ordering universe. Multi-namespace makes sense for density tokens but not for z-index. - Hand-editing the generated CSS marker block. Edit
z-index-tokens.tsand re-runpnpm gen:z-index. Hand edits are wiped on next codegen and causepnpm check:z-indexto fail in CI. - Skipping the lint rule. Without enforcement, raw
z-index: 100reappears within a sprint and the system slowly returns to magic-number chaos.
When to Use
Good fit
- Projects with 5+ overlay types β once modal, drawer, popover, tooltip, toast, and a sticky toolbar coexist, ad-hoc numbers stop scaling
- Multi-team codebases β a shared semantic scale prevents one teamβs
z-index: 100from colliding with another teamβsz-index: 200 - Projects with a styleguide / design system page β the generated table becomes the canonical reference for designers and developers
Not needed
- Prototypes and single-overlay sites β a marketing landing page with one mobile menu does not need a tier system
- Static content sites β blog and documentation sites with no overlays beyond the menu can use
z-index: 1and stop there - One-off internal tools β if the entire app has three z-index values total, the tier system is overhead
References
- Stacking Context β what creates a stacking context and how to debug layering bugs
- Multi Namespace Token Strategy β companion strategy for density tokens (spacing, font sizes); contrast with z-indexβs single-namespace rule
- pgen worked example (zudo-pattern-gen #940) β concrete migration from magic numbers to the tier-token system