zudo-css-wisdom

Type to search...

to open search from anywhere

Z-Index Strategy

CreatedApr 25, 2026UpdatedApr 25, 2026Takeshi Takatsudo

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 /* must be above modal */ 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 99999 reflex. 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) and 200 (toast) means picking 150 and praying nothing in the codebase already uses it.
  • Tailwind’s z-10 / z-20 / z-30 scale. 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:

  1. Semantic names β€” --z-modal tells you what layer it is. --z-200 does not.
  2. 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.)
  3. 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.
Semantic Tier Tokens vs Magic Numbers

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 costPre-AI (manual refactor)AI-era refactor
Renaming --z-200 β†’ --z-toast across 300 filesHours of find-and-replace, easy to breakSeconds, structural rename
Inserting a tier between two existing valuesPainful β€” may force renumbering downstreamTrivial β€” regenerate the scale, AI updates references
Memorising β€œwhat does z-30 mean in our app?”Required onboarding docEliminated β€” 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 categoryNamespaceReason
Spacing, font sizesMultiDifferent design contexts have different density
Colors, font familiesSingle (shared)Brand identity is global
Z-indexSingle (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:

ScriptPurposeWhen to run
pnpm gen:z-indexRewrites the GENERATED:Z_INDEX_* marker block in App.css from Z_INDEX_TIERSAfter editing z-index-tokens.ts
pnpm check:z-indexRe-runs codegen to a temp file and diffs against the committed CSS β€” non-zero exit on driftPre-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-N only 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.

Local Promotion Inside a Stacking Context

πŸ“ β€”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.

Sibling-Ordered Global Overlays

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.

AllowedForbidden
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 /* design-token-lint-ignore */ escape hatchBare 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.

SituationRender modeToken
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 escapePortaled to <body>--z-popover-portaled
Popover must always sit above a modalPortaled--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-N outside a stacking context. Without an isolated parent, --z-local-1 participates in the root stacking context and competes against global tiers. Either add isolation: isolate to 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.ts and re-run pnpm gen:z-index. Hand edits are wiped on next codegen and cause pnpm check:z-index to fail in CI.
  • Skipping the lint rule. Without enforcement, raw z-index: 100 reappears 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: 100 from colliding with another team’s z-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: 1 and stop there
  • One-off internal tools β€” if the entire app has three z-index values total, the tier system is overhead

References

Revision History