← Blog8 min read#elevation#shadow#z-index

Elevation and Shadow System in CSS: Z-index, Shadows and Depth

Build a real elevation system in CSS - from z-index stacking contexts to layered box-shadows and design tokens that scale across your whole UI.

CSS elevation layers showing shadow depth and stacking visual hierarchy

Why "Just Add a Box Shadow" Isn't a System

Most codebases have the same shadow problem. A developer adds box-shadow: 0 2px 4px rgba(0,0,0,0.1) to a card. Two weeks later someone else writes box-shadow: 0 4px 16px rgba(0,0,0,0.15) on a modal. Six months in, you've got eight slightly different shadow values across forty components and nothing feels cohesive. Sound familiar?

The fix isn't hard - it's just work you have to do once. An elevation system treats depth as a design token, not a one-off CSS property. You define a small set of levels (typically four to six), map each to a specific shadow, and reference the token everywhere. The result is a UI that feels physically consistent, where cards sit closer to the surface than dialogs, and tooltips float visibly above both.

Honestly, this is the thing that separates production design systems from hobby projects. Material Design 3 shipped its elevation model in 2021 and it still holds up - five levels, each with a distinct surface tint and shadow. You don't have to copy it, but the concept is worth stealing.

Before we get into code, it's worth understanding the two pillars of elevation: shadows (which fake depth visually) and z-index (which controls actual stacking order in the browser). They're related but not the same thing, and conflating them is what causes most of the bugs you've probably debugged at 11pm.

Z-index and Stacking Contexts: The Rules Nobody Explains

Z-index is the most misunderstood property in CSS. The classic mistake is cranking it to z-index: 9999 and wondering why the element is still behind something. The answer is stacking contexts - and once you get them, z-index becomes predictable.

A stacking context is a self-contained z-order universe. Elements inside it are ordered relative to each other, and the whole context is ordered as a unit relative to its parent stacking context. You create one (often accidentally) whenever you set position + z-index, opacity < 1, transform, filter, will-change, isolation: isolate, or a few others. So opacity: 0.99 on a wrapper will trap your modal's z-index: 9999 inside that wrapper's stacking context - and if that wrapper has z-index: 1, your modal loses to anything with z-index: 2 at the parent level.

The practical fix is to define a global z-index scale as CSS custom properties and respect it everywhere: ``css :root { --z-base: 0; --z-raised: 10; --z-dropdown: 100; --z-sticky: 200; --z-overlay: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600; } `` Those gaps aren't decoration - they give you room to insert new layers without renumbering everything. Put this in your design tokens file alongside your colors and spacing.

Worth noting: isolation: isolate is your best friend for containing third-party components. Wrap any vendor widget in a div with isolation: isolate and it can't escape its stacking context and mess up your modal stack. No more z-index arms races with embedded maps or chat widgets.

One more thing - the browser devtools in Chrome 2024+ let you inspect stacking contexts in the Layers panel. If a z-index value isn't working, open that panel first before guessing.

Building a Shadow Scale from First Principles

A good shadow system uses multiple shadow layers per level. A single shadow looks flat and fake. Layering two or three shadows - each with different offsets, spreads, and opacities - mimics how real light behaves and produces depth that reads well on both light and dark backgrounds.

Here's the pattern. Each level gets a key shadow (the directional one, usually pointing down-right) plus an ambient shadow (soft, symmetrical, no offset). Sometimes a third highlight layer on top gives you a rim-light feel, especially useful for glassmorphism components: ``css :root { /* Level 0 - flat, no shadow */ --shadow-0: none; /* Level 1 - 2px lift, e.g. buttons at rest */ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); /* Level 2 - 4px lift, e.g. cards */ --shadow-2: 0 2px 4px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); /* Level 3 - 8px lift, e.g. dropdowns */ --shadow-3: 0 4px 8px rgba(0, 0, 0, 0.14), 0 8px 24px rgba(0, 0, 0, 0.10), 0 2px 4px rgba(0, 0, 0, 0.06); /* Level 4 - 16px lift, e.g. modals */ --shadow-4: 0 8px 16px rgba(0, 0, 0, 0.16), 0 16px 48px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08); /* Level 5 - 24px lift, e.g. toasts, command palettes */ --shadow-5: 0 12px 24px rgba(0, 0, 0, 0.18), 0 24px 64px rgba(0, 0, 0, 0.14), 0 6px 12px rgba(0, 0, 0, 0.08); } ``

In practice, the key numbers to tune are the Y offset (controls how "high" the element feels) and the blur radius. A rough rule: blur β‰ˆ 2Γ— the Y offset for the key shadow. The ambient shadow should have 2–3Γ— more blur and half the opacity. Don't set X offsets unless you're going for a deliberate side-light aesthetic.

You can preview and fine-tune values in real time with the box shadow generator - it lets you stack multiple shadow layers visually and copy the CSS directly. Way faster than tweaking numbers blind in a stylesheet.

Wiring the System: Elevation Tokens in Practice

Having tokens defined is half the job. The other half is making sure your components actually use them, and that you have a semantic layer so you're not writing var(--shadow-3) and trying to remember what level 3 means in context.

Add a semantic alias layer on top of the raw scale: ``css :root { --elevation-card: var(--shadow-2); --elevation-dropdown: var(--shadow-3); --elevation-modal: var(--shadow-4); --elevation-toast: var(--shadow-5); --elevation-button-hover: var(--shadow-2); --elevation-button-active: var(--shadow-1); } .card { box-shadow: var(--elevation-card); z-index: var(--z-base); } .dropdown { box-shadow: var(--elevation-dropdown); z-index: var(--z-dropdown); } .modal { box-shadow: var(--elevation-modal); z-index: var(--z-modal); } `` Now the mapping is explicit. If you ever rebrand and want all modals to feel heavier, you change one line.

Buttons deserve special attention because elevation should respond to interaction. Use transitions for smooth feedback - 150ms is about right for hover, 80ms for active press: ``css .btn { box-shadow: var(--shadow-1); transition: box-shadow 150ms ease, transform 80ms ease; } .btn:hover { box-shadow: var(--elevation-button-hover); transform: translateY(-1px); } .btn:active { box-shadow: var(--shadow-0); transform: translateY(1px); } ` That 1px translate on hover reinforces the illusion - the card is rising toward you. The translateY(1px)` on active pushes it back down like a physical button. Small detail, big payoff.

This pairs directly with the work you'd do in a design tokens guide - elevation is just another axis in your token hierarchy, alongside color and spacing. If you've already set up a spacing system, slotting shadows in is maybe 30 minutes of work.

Quick aside: if you're using Tailwind, you can expose this in tailwind.config.js under theme.extend.boxShadow and theme.extend.zIndex. Then you get utility classes like shadow-card and z-modal that your whole team understands without reading docs.

Dark Mode Shadows: The Part Everyone Gets Wrong

Shadows on dark backgrounds don't work. This is genuinely the most overlooked part of any elevation system. A dark surface already absorbs light - adding a dark shadow on top of it is invisible. The result is a flat UI where modal dialogs and dropdowns look glued to the background.

Two approaches work well. First, surface tinting: elevated components get progressively lighter backgrounds in dark mode. This is what Material You does - a card at level 2 might be hsl(220, 10%, 18%) while the page background is hsl(220, 10%, 12%). The difference in lightness communicates depth without needing shadows at all. Six percentage points of lightness can replace an entire shadow layer.

Second, if you want to keep shadows in dark mode, shift them from black to a very dark version of your brand color, and dramatically increase the blur: ``css @media (prefers-color-scheme: dark) { :root { --shadow-2: 0 2px 4px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3); --shadow-4: 0 8px 16px rgba(0, 0, 0, 0.5), 0 16px 48px rgba(0, 0, 0, 0.4); } /* Surface tint for elevated elements */ .card { background-color: hsl(220, 10%, 18%); } .modal { background-color: hsl(220, 10%, 22%); } } ``

Look, the surface tint approach is genuinely better. It's more accessible, it works without transparency artifacts, and it's exactly what apps like Figma and Linear do in their dark modes. The shadows-on-dark approach is a compromise - it can work but you need much higher opacity values (0.4–0.6) to see any effect.

If you're building dark-mode-first components - glassmorphism or dark glass UIs especially - consider adding an inset highlight shadow at 1px on the top edge: inset 0 1px 0 rgba(255, 255, 255, 0.06). That single line does a lot of visual work to suggest a light source above the component.

Elevation in Motion: Animated Depth on Interaction

Static elevation is table stakes. Where systems really differentiate themselves is in transitions between elevation levels - the card that rises when you hover, the dialog that drops in from above, the toast that floats up from the bottom edge of the viewport.

The key principle: elevation transitions should feel physical. Higher elevation elements enter from further away (larger initial scale or translate). Lower elevation things shouldn't have dramatic entrances. Here's a modal enter animation that respects this: ``css @keyframes modal-enter { from { opacity: 0; transform: translateY(-16px) scale(0.97); box-shadow: var(--shadow-2); } to { opacity: 1; transform: translateY(0) scale(1); box-shadow: var(--shadow-4); } } .modal { animation: modal-enter 240ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } `` That cubic-bezier is a mild spring - it overshoots 8–10% and snaps back. Feels alive without being bouncy.

For drag-and-drop (think Kanban cards), animate elevation on grab. When a user picks up a card, it should visually lift - increase shadow, apply a slight scale, maybe rotate 2–3 degrees: ``css .card.is-dragging { box-shadow: var(--shadow-5); transform: scale(1.03) rotate(1.5deg); transition: box-shadow 120ms ease, transform 120ms ease; z-index: var(--z-overlay); } `` That transform + shadow combo is what makes drag-and-drop feel premium vs. janky. Worth the twenty lines of CSS.

That said, respect prefers-reduced-motion. Wrap any animation-heavy code: ``css @media (prefers-reduced-motion: reduce) { .modal { animation: none; } .card.is-dragging { transform: none; } } `` Keep the shadow changes - they're not motion - but kill the transforms. Your users with vestibular disorders will thank you.

Putting It All Together: A Production-Ready Elevation System

Here's what a complete elevation system looks like when you wire everything together. The CSS custom property file is the single source of truth - every component imports from it, nothing hardcodes a shadow value: ``css /* tokens/elevation.css */ :root { /* Z-index scale */ --z-base: 0; --z-raised: 10; --z-dropdown: 100; --z-sticky: 200; --z-overlay: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600; /* Raw shadow scale */ --shadow-0: none; --shadow-1: 0 1px 2px rgba(0,0,0,.12), 0 1px 3px rgba(0,0,0,.08); --shadow-2: 0 2px 4px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04); --shadow-3: 0 4px 8px rgba(0,0,0,.14), 0 8px 24px rgba(0,0,0,.10), 0 2px 4px rgba(0,0,0,.06); --shadow-4: 0 8px 16px rgba(0,0,0,.16), 0 16px 48px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.08); --shadow-5: 0 12px 24px rgba(0,0,0,.18), 0 24px 64px rgba(0,0,0,.14), 0 6px 12px rgba(0,0,0,.08); /* Semantic aliases */ --elevation-surface: var(--shadow-0); --elevation-card: var(--shadow-2); --elevation-dropdown: var(--shadow-3); --elevation-modal: var(--shadow-4); --elevation-toast: var(--shadow-5); } @media (prefers-color-scheme: dark) { :root { --shadow-2: 0 2px 4px rgba(0,0,0,.4), 0 4px 16px rgba(0,0,0,.3); --shadow-4: 0 8px 16px rgba(0,0,0,.5), 0 16px 48px rgba(0,0,0,.4); } } ``

Pair this with your color system and spacing system and you've got three of the four pillars of a real design token architecture. Add typography and you're done. That's not hyperbole - those four files can govern a production app with hundreds of components.

One thing I'd add to any team's process: document the elevation scale visually. A single page in Storybook or a Notion doc showing each level with its shadow and z-index value saves hours of back-and-forth with designers. You can even use the gradient generator or box shadow generator to generate reference previews quickly.

Is this overkill for a landing page? Probably. But if you're building anything with a design system, a dashboard, or a component library, an explicit elevation model pays for itself the first time a designer asks "why does the tooltip go behind the modal" and you can answer them in one sentence.

FAQ

Why does my z-index value seem to do nothing?

You're almost certainly inside a stacking context created by a parent with opacity, transform, filter, or similar. Add isolation: isolate to that parent or remove the property causing the context. Use Chrome's Layers panel to diagnose it fast.

How many shadow levels do I actually need?

Four to six covers virtually every real UI case: flat, card, dropdown, modal, toast, and tooltip. More than six and your distinctions get too subtle to perceive - users won't notice the difference between level 7 and level 8.

Should z-index values match elevation levels numerically?

No - they're separate concerns. A card can be elevation level 2 visually but sit at z-index 0 because nothing overlaps it. z-index only matters when elements overlap; shadow communicates depth regardless.

How do I handle shadows on dark mode backgrounds?

Use surface tinting (lighter background on elevated elements) as your primary approach, then increase shadow opacity to 0.4–0.5 in your dark media query. Pure black shadows on dark surfaces are nearly invisible at default opacity values.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

Building Design Systems That Scale: Engineering Guide 2026 β†’Container Queries for Components: Component-Driven Responsive Design β†’Animation in Design Systems: Tokens, Reduced Motion, Choreography β†’Neumorphism in Tailwind CSS: Soft Shadows Without the Opacity Trap β†’