Emulate a Light Source
- Imagine light coming from above (natural)
- Top edges are lighter
- Bottom edges are darker
- Creates 3D effect
Shadows:
- Light source above means shadows below
- Shadows help convey elevation
Use Shadows to Convey Elevation
- Small, subtle shadows = element slightly raised
- Large, soft shadows = element floating high
- No shadow = flat on page
Shadow guidelines:
- Small elements = small shadows
- Large elements can have larger shadows
- More elevation = larger, softer, more offset shadow
Define a shadow system:
- 5 shadow levels usually enough
- xs, sm, md, lg, xl
Materials Explain Layers
Use material effects to explain hierarchy, not to decorate. A translucent, blurred, or elevated surface should answer two questions: "what is above what?" and "does the background still matter while this layer is open?"
Use translucent materials when:
- The parent context is still useful for orientation
- The layer is temporary: sheet, popover, command surface, inspector
- Seeing a hint of what sits behind helps dismissal feel like returning
Use opaque surfaces when:
- The background is noisy or irrelevant
- Readability would suffer
- The task needs full attention
Rules:
- Keep foreground text and controls readable before chasing material effects
- Do not stack multiple glassy layers unless each layer has a clear job
- Pair material changes with the depth scale: higher surfaces get clearer separation
- Background blur should be subtle and purposeful; heavy blur is expensive and can make the UI feel foggy
- Do not recreate platform chrome unless the product is intentionally living inside that platform context
Shadows Can Have Two Parts
Most realistic shadows have two layers:
Shadow 1: Sharp and offset
- Simulates direct light
- Small blur radius
- Offset downward
Shadow 2: Larger and softer
- Simulates ambient light
- Large blur radius
- Minimal offset
Example:
box-shadow:
0 1px 3px oklch(0 0 0 / 0.12),
0 1px 2px oklch(0 0 0 / 0.24);
Shadow Scale Reference
shadow-xs: 0 1px 2px oklch(0 0 0 / 0.05)
shadow-sm: 0 1px 3px oklch(0 0 0 / 0.12), 0 1px 2px oklch(0 0 0 / 0.24)
shadow-md: 0 3px 6px oklch(0 0 0 / 0.15), 0 2px 4px oklch(0 0 0 / 0.12)
shadow-lg: 0 10px 20px oklch(0 0 0 / 0.15), 0 3px 6px oklch(0 0 0 / 0.1)
shadow-xl: 0 15px 25px oklch(0 0 0 / 0.15), 0 5px 10px oklch(0 0 0 / 0.05)
Three-Tier Shadow System
For more polished depth, use THREE layers per shadow:
:root {
--shadow-s:
inset 0 1px 2px oklch(1 0 0 / 0.3), /* Top highlight - simulates light source */
0 1px 2px oklch(0 0 0 / 0.3), /* Contact shadow - sharp, close */
0 2px 4px oklch(0 0 0 / 0.15); /* Ambient shadow - soft, diffused */
--shadow-m:
inset 0 1px 2px oklch(1 0 0 / 0.5), /* Stronger highlight */
0 2px 4px oklch(0 0 0 / 0.3), /* Medium contact shadow */
0 4px 8px oklch(0 0 0 / 0.15); /* Larger ambient shadow */
--shadow-l:
inset 0 1px 2px oklch(1 0 0 / 0.7), /* Strongest highlight */
0 4px 6px oklch(0 0 0 / 0.3), /* Larger contact shadow */
0 6px 10px oklch(0 0 0 / 0.15); /* Maximum ambient shadow */
}
Shadow Anatomy
Layer 1 - Inset Highlight:
inset= inner glow effect0 1px 2px= positioned just inside top edgeoklch(1 0 0 / 0.3 | 0.5 | 0.7)= white at 30%/50%/70% opacity- Purpose: Simulates light hitting the top surface
Layer 2 - Contact Shadow:
0 Xpx Ypx= X is offset, Y is bluroklch(0 0 0 / 0.3)= black at 30% opacity- Purpose: Sharp shadow where element "touches" surface
- Scales: 1px→2px→4px offset, 2px→4px→6px blur
Layer 3 - Ambient Shadow:
- Larger offset and blur than contact shadow
oklch(0 0 0 / 0.15)= black at 15% opacity (very soft)- Purpose: Diffused environmental shadow
- Scales: 2px→4px→6px offset, 4px→8px→10px blur
When to Use Each Shadow
Small (--shadow-s):
- Default cards and containers
- Subtle elevation
- Always-visible UI elements
.card { box-shadow: var(--shadow-s); }
Medium (--shadow-m):
- Hover states
- Focused elements
- Moderate emphasis
.card:hover { box-shadow: var(--shadow-m); }
Large (--shadow-l):
- Modals and dialogs
- Popovers and dropdowns
- Maximum elevation
.modal { box-shadow: var(--shadow-l); }
Creating Custom Shadows
Follow the three-layer pattern:
--shadow-custom:
inset 0 1px 2px oklch(1 0 0 / 0.4), /* Adjust opacity for theme */
0 [offset] [blur] oklch(0 0 0 / [opacity]), /* Contact layer */
0 [larger-offset] [larger-blur] oklch(0 0 0 / [lower-opacity]); /* Ambient layer */
Scaling rules:
- Highlight opacity: 30% → 50% → 70%
- Contact shadow: 30% black, scale offset/blur proportionally
- Ambient shadow: 15% black, always softer than contact
- Offset ratio: ~1:2 (contact:ambient)
- Blur ratio: ~1:2 (contact:ambient)
Dark-Mode Layered Elevation Scale
On a dark surface a single drop shadow barely reads: a dark element on a dark background has little contrast, so depth comes from a stack of layers, not one blur. The recipe pairs two ideas.
- A rim that draws the material edge. A top inset highlight (light hitting the upper lip) plus an inner and outer hairline, so the surface has a crisp, self-lit edge on black.
- A penumbra built from stacked drop shadows whose blur grows across the layers (1, 3, 6, 12px: the first step triples, the rest double) with a negative spread of about half the blur, at the same low opacity on every layer. Growing the blur while shrinking the spread fakes a soft, physically plausible falloff that a single shadow cannot.
Elevation is cumulative: each higher level keeps the rim and adds one more, larger drop layer. Level 1 rests on the surface; level 5 floats highest (sticky bars, floating toolbars).
Shared rim (present on every level):
inset 0 1px 0 0 oklch(1 0 0 / 0.08), /* top highlight, catches the light */
inset 0 0 0 1px oklch(1 0 0 / 0.04), /* inner hairline, defines the lip */
0 0 0 1px oklch(0 0 0 / 0.16) /* outer ring, separates from background */
Drop layers add up as elevation grows (opacity 0.18 on each, blur growing, spread about half the blur). This is the buildup, not copy-paste CSS: each level is the rim followed by every drop layer down to its own. See the complete level-5 declaration below.
level drop layers (each appended to the rim)
elevation-1 0 1px 1px -0.5px
elevation-2 ... + 0 3px 3px -1.5px
elevation-3 ... + 0 6px 6px -3px
elevation-4 ... + 0 12px 12px -6px
elevation-5 rim + all four drop layers (full declaration below)
Full level 5, the top of the scale (sticky / floating bar):
--dm-elevation-5:
inset 0 1px 0 0 oklch(1 0 0 / 0.08),
inset 0 0 0 1px oklch(1 0 0 / 0.04),
0 0 0 1px oklch(0 0 0 / 0.16),
0 1px 1px -0.5px oklch(0 0 0 / 0.18),
0 3px 3px -1.5px oklch(0 0 0 / 0.18),
0 6px 6px -3px oklch(0 0 0 / 0.18),
0 12px 12px -6px oklch(0 0 0 / 0.18);
When to use each level:
- 1: resting cards, list rows
- 2: hover or raised state
- 3: dropdowns, popovers, menus
- 4: dialogs, modals, sheets
- 5: sticky headers, floating toolbars, command bars
The negative spread is the point. Without it every layer would bleed the same width and the stack would look like one fat shadow. Shrinking each layer inward keeps the contact edge tight while the outer layers spread, which is what sells the softness.
Using Shadows Instead of Borders
Instead of using a border in light mode, prefer a subtle box-shadow that adds more depth to the element.
Base Shadow (3 layers)
.border-shadow {
box-shadow:
0px 0px 0px 1px oklch(0 0 0 / 0.06),
0px 1px 2px -1px oklch(0 0 0 / 0.06),
0px 2px 4px 0px oklch(0 0 0 / 0.04);
}
Hover State
For the hover state, it is the same box-shadow just slightly darker. To transition between the shadows we can add box-shadow to the transition property like this transition-[colors, box-shadow].
.border-shadow:hover {
box-shadow:
0px 0px 0px 1px oklch(0 0 0 / 0.08),
0px 1px 2px -1px oklch(0 0 0 / 0.08),
0px 2px 4px 0px oklch(0 0 0 / 0.06);
}
Why Shadows Over Borders
Using shadows instead of borders also has a benefit when using images or multiple colors as backgrounds. Shadows are versatile and they adapt really well to any kind of background, since they use transparency.
Solid colors on the other hand don't work as well when used on a background other than they are intended to be used on.
Benefits
- Works with any background (images, gradients)
- Uses transparency so adapts automatically
- Adds subtle depth instead of flat lines
- Better than solid borders on complex backgrounds
Dark Mode: Use Inset Shadows, Not Inverted Ones
On a light UI, a subtle downward shadow (dark, low alpha) reads as depth because the surface is lighter than the shadow beneath it. In dark mode that contrast disappears: a dark shadow on a dark surface is invisible. The instinct is to "invert" the shadow, flip it to a light or white outer shadow, but an outer glow pointing up reads as an unnatural halo, not depth. Light still comes from above, so a bright shadow below the object contradicts the light source and looks wrong.
Instead, give elements that still need depth a thin inset highlight: a 0.5px to 1px inset shadow using a low alpha white on the top edge. It paints a hairline rim inside the box, catching the imagined overhead light on the top edge exactly like a real raised surface, with no faked upward glow.
/* Light mode: a soft downward shadow carries the depth */
.card {
box-shadow:
0px 0px 0px 1px oklch(0 0 0 / 0.06),
0px 1px 2px -1px oklch(0 0 0 / 0.06);
}
/* Dark mode: the outer shadow vanishes, so add a thin inset top highlight */
.dark .card {
box-shadow:
inset 0 1px 0 0 oklch(1 0 0 / 0.08), /* 1px top-edge highlight */
inset 0 0 0 1px oklch(1 0 0 / 0.04); /* faint full rim, optional */
}
Guidelines:
- Keep it thin: 0.5px to 1px. Anything thicker reads as a border, not a catch of light.
- Keep the alpha low, roughly 0.04 to 0.10 white. Depth in dark mode is a hint, not a spotlight.
- The top edge is the one that matters. A full inset ring is fine, but keep it fainter than the top line.
- Do not flip a light-mode outer shadow to white and push it upward. An upward light shadow fights the overhead light source and looks fake.
Outline Images for Depth
Photos and screenshots placed directly on a page float awkwardly: a bright image on a light background has no edge, and a screenshot of a light UI blends into the page. Add a hairline inset outline so the image reads as a distinct, slightly recessed surface instead of bleeding into the background.
img,
video {
border-radius: 12px;
/* inset so it sits ON TOP of the image edge, not outside the box */
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.1);
}
Why inset rather than a border or outer shadow:
- A real
borderadds to the box size and can shift layout; an inset shadow paints inside the existing box, so nothing reflows. - The outline sits on top of the image's own pixels, so a light screenshot still gets a crisp edge where a same-color background would otherwise hide it.
- Because it uses transparency (
oklch(0 0 0 / 0.1)), it adapts to any background, same reasoning as shadows over borders above.
On dark backgrounds, flip to a light hairline (inset 0 0 0 1px oklch(1 0 0 / 0.1)) so the edge stays visible. Combine with a soft outer shadow when the image should also lift off the page, the inset gives the crisp edge, the outer shadow gives the elevation.
Even Flat Designs Can Have Depth
- Depth ≠ skeuomorphism
- Use subtle techniques:
- Light borders on top edge
- Dark borders on bottom edge
- Subtle gradients
- Layering (overlapping elements)
Overlap Elements to Create Layers
- Overlapping creates depth through layering
- Pull elements out of their container
- Offset elements on top of backgrounds
- Creates visual interest and hierarchy
Combining Color and Shadows
.elevated-card {
background: var(--bg); /* oklch(0.2 0 264) */
box-shadow: var(--shadow-s); /* Subtle depth */
transition: background 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.elevated-card:hover {
background: var(--bg-light); /* oklch(0.3 0 264) - lighter */
box-shadow: var(--shadow-m); /* More depth */
}
Why this works:
- OKLCH ensures smooth color transitions
- Shadows create physical depth perception
- Both scale together for consistent elevation feel
- Light theme automatically adjusts via CSS variables
Shadow Code Rules
When writing shadow code:
- Choose appropriate shadow tier for elevation
- Apply via
box-shadow: var(--shadow-[s/m/l]) - Transition shadows on interactive states
- Never use single-layer shadows
- Consider shadow visibility on both themes
Theme compatibility: Both OKLCH colors and shadows work across themes because:
- CSS variables update automatically with
.lightclass - OKLCH perceptual uniformity maintains contrast ratios
Dark outer shadows are NOT theme-agnostic: a dark shadow on a dark surface is invisible, and inverting it to a white outer glow looks fake. In dark mode, swap the outer shadow for a thin inset top highlight (see "Dark Mode: Use Inset Shadows, Not Inverted Ones").
Separate the Object From Its Shadow
When an element lifts away from a surface, its shadow does not have to travel with it. Treat the object and its shadow as two independent layers: animate the object upward, but keep the shadow grounded on the surface and fade it as the gap grows.
Why it works:
- A shadow that moves locked to the object reads as one flat sticker sliding around
- A shadow that stays anchored and softens with distance reads as a real object rising into the light
- The growing gap between object and shadow is the depth cue, so the eye reads height even on a 2D screen
How to build it:
- Render the shadow as its own element pinned to the surface, not as a
box-shadowon the object - As the object lifts, lower the shadow's opacity and shrink it horizontally (a higher object casts a fainter, tighter shadow)
- Increase the shadow's blur slightly with height to mimic ambient light spreading
- Animate the object and the shadow separately so each can use its own easing and duration
/* Wrapper establishes containing block for the shadow */
.surface {
position: relative;
}
/* Object and shadow are siblings, animated independently */
.object {
position: relative;
transition: transform 900ms cubic-bezier(.34, 1.4, .64, 1);
}
.object:hover,
.object:focus-visible,
.object.is-lifted {
transform: translateY(-120px);
}
.shadow {
position: absolute;
bottom: 0;
left: 50%;
filter: blur(28px);
opacity: 0.5;
transform: translateX(-50%) scaleX(1);
transition: opacity 900ms ease-out,
transform 900ms ease-out,
filter 900ms ease-out;
}
.object:hover ~ .shadow,
.object:focus-visible ~ .shadow,
.object.is-lifted ~ .shadow {
opacity: 0.1; /* fades as the object rises */
filter: blur(53px); /* softens with distance */
transform: translateX(-50%) scaleX(0.45); /* tightens horizontally */
}
When to use it:
- Reserve it for moments that deserve emphasis: a card being picked up, a dialog rising, a featured element settling into place
- It is a deliberate accent, not a default. Applying it to every elevation change makes an interface feel busy
- For ordinary resting elevation, a static two-part or three-part
box-shadowis the right tool
Border Radius Scale
rounded-sm: 2px
rounded: 4px
rounded-md: 6px
rounded-lg: 8px
rounded-xl: 12px
rounded-2xl: 16px
rounded-full: 9999px
Scale radius with component size
Do not reuse one corner radius across the whole UI. As a component grows, its radius should grow with it to preserve the same visual softness and balance. A radius that looks soft on a small chip looks almost square on a large panel; the same radius on a tiny control collapses into a full circle.
Rough size tiers:
XS (buttons, tags, chips): ~12px
S (cards, inputs, dropdowns): ~16px
M (modals, panels, sheets): ~24px
These are starting points, not absolutes. Each design system sets its own base --radius and a derived scale (--radius-sm/md/lg/xl); keep the relationship (small components a notch tighter, large surfaces a notch rounder) rather than copying the exact pixels.
Hard rule for tiny controls (checkbox, switch thumb, small badge): the corner radius must stay below half the smallest side, or the browser clamps it to a circle and a checkbox reads as a radio. A 12px radius on an 18px checkbox becomes a circle (max is 9px); drop it to roughly 8px so it reads as a rounded square distinct from the radio.
Concentric Radius for Nested Elements
When one rounded element sits inside another (a button inside a card, an avatar inside a tile, an input inside a padded panel), their corners must be concentric, sharing the same center of curvature, or the gap between the two curves looks uneven and the nesting reads as a mistake.
The rule: inner radius = outer radius − gap, where the gap is the padding between the two elements.
outer radius (card): 16px
padding (gap): 8px
inner radius (button): 16 − 8 = 8px
.card {
border-radius: 16px;
padding: 8px;
}
.card > .button {
/* concentric: outer minus the padding */
border-radius: 8px; /* or: calc(16px - 8px) */
}
With CSS variables you can derive it so it stays correct when the radius changes:
.card {
--card-radius: 16px;
--card-padding: 8px;
border-radius: var(--card-radius);
padding: var(--card-padding);
}
.card > .button {
border-radius: calc(var(--card-radius) - var(--card-padding));
}
Notes:
- If the math goes to zero or negative (padding ≥ outer radius), the inner element should just be square (
border-radius: 0), forcing a positive radius there breaks the concentric relationship. - This is the most common giveaway of a hand-built vs a carefully-built UI: matching radii (same value inside and out) looks wrong precisely because the curves are not concentric. Subtract the gap.
Compositing
An interface is a paint stack. Opacity, blending, clipping, filters, and shadows behave according to where layers are grouped, not merely which values they use.
Important rules:
- use
isolation: isolateto prevent blend modes from leaking into unrelated ancestors; - fade a group when its overlapping children should retain their internal appearance;
- avoid stacking adjacent translucent borders that form a darker double line;
- use an additive group for cross-fades when the midpoint would otherwise look dim or washed out;
- pair a tight contact shadow with a broader ambient shadow instead of asking one shadow to do both jobs;
- apply backdrop blur and modest saturation only where the surface truly samples content behind it.
.material-card {
isolation: isolate;
overflow: clip;
background: oklch(99% 0.006 255 / 0.78);
border: 1px solid oklch(25% 0.02 255 / 0.1);
box-shadow:
0 1px 2px oklch(15% 0.03 255 / 0.12),
0 18px 48px oklch(15% 0.03 255 / 0.14);
backdrop-filter: blur(20px) saturate(1.12);
}
.fading-group {
opacity: var(--group-opacity);
}
.crossfade {
isolation: isolate;
mix-blend-mode: plus-lighter;
}
For graphics that must respond to content, theme, or interaction, assembling primitives in code is often more robust than exporting every possible static variation.
Checklist
- Use shadows to convey elevation
- Light source from above
- Two-part (or three-part) shadows for realism
- Subtle depth even in flat designs
- Define a shadow scale (xs to xl)
- Prefer shadow-borders over solid borders in light mode
- In dark mode, swap outer shadows for a thin (0.5-1px) inset top highlight, never an inverted white glow
- Transition shadows on hover/focus states
- For emphasis moments, detach the shadow and fade it by lift distance
- Border radius consistent across components
- Radius scales with component size (XS ~12 / S ~16 / M ~24); tiny controls stay below half their side so they do not clamp to a circle
- Nested elements use concentric radius (inner = outer − padding)
- Images get a hairline inset outline so they don't bleed into the background