Elevation, borders & shadows
Lopifi's most opinionated decision:
At rest, a surface is defined by a hairline. A shadow means the thing is actually floating.
Most dashboards get this backwards. Cards get shadow-md as decoration, and then a dropdown that really does float has nowhere to go. When everything is elevated, nothing is.
The two-tier model#
Tier 1 — Structural (no shadow)#
Anything that scrolls with the page and has a fixed spot in the document:
- Cards, panels, sections
- Tables, table headers, table footers
- Sidebars, toolbars, tab bars
- Inputs, buttons at rest
- Every statement, every chart container
These get a 1px border and nothing else. Depth comes from the surface being lighter than the canvas (in light mode, white on #fafafb), not from a shadow.
Tier 2 — Floating (shadow)#
Anything that sits above the document temporarily and covers what's under it:
- Popovers, dropdowns, menus, comboboxes, date pickers
- Dialogs, sheets, drawers
- Toasts
- Command palette
- Elements being dragged
- Sticky headers only once content has scrolled beneath them
These get a shadow from the scale below, sized by how high above the page they sit.
Hairline at rest, shadow when floating
Shadow construction#
Real shadows come from two kinds of light: broad ambient occlusion and a tighter directional cast. A one-layer shadow looks like a Photoshop default.
Every Lopifi shadow has ≥ 2 layers, following:
blur ≈ 2 × y-offset
spread ≈ −0.5 × blur (keeps the shadow tucked under the element)
ambient layer: larger blur, lower alpha, larger negative spread
direct layer: tighter blur, higher alpha
Shadows are tinted, never pure black. Use
oklch(0.21 0.02 265 / α), the neutral ink hue at low chroma. A pure-black shadow on a cool-neutral surface turns muddy brown-gray. A hue-matched shadow reads as shade.
Every shadow is two lights
The scale#
--shadow-color: 0.21 0.02 265; /* L C H of the ink, alpha applied per layer */
/* e0 — resting. Not a shadow. */
--elevation-0: none;
/* surfaces use: border: 1px solid var(--border) */
/* e1 — lifted: hover on an interactive card, a selected row */
--elevation-1:
0 1px 2px -1px oklch(var(--shadow-color) / 0.10),
0 1px 1px 0px oklch(var(--shadow-color) / 0.04);
/* e2 — popover, dropdown, menu, tooltip, combobox */
--elevation-2:
0 4px 8px -2px oklch(var(--shadow-color) / 0.10),
0 2px 4px -2px oklch(var(--shadow-color) / 0.06),
0 0 0 1px oklch(var(--shadow-color) / 0.05);
/* e3 — dialog, sheet, drawer */
--elevation-3:
0 16px 32px -8px oklch(var(--shadow-color) / 0.14),
0 6px 12px -6px oklch(var(--shadow-color) / 0.10),
0 0 0 1px oklch(var(--shadow-color) / 0.05);
/* e4 — dragging, command palette. The only thing this high. */
--elevation-4:
0 32px 64px -16px oklch(var(--shadow-color) / 0.24),
0 12px 24px -12px oklch(var(--shadow-color) / 0.14),
0 0 0 1px oklch(var(--shadow-color) / 0.06);
Note the trailing 0 0 0 1px ring on e2–e4. Floating surfaces need a hairline and a shadow, because a shadow alone doesn't define the edge on a light background.
Dark mode elevation#
A black shadow on a near-black canvas is nearly invisible, so dark mode uses a different model: light from above.
[data-theme="dark"] {
--shadow-color: 0 0 0; /* true black; there's nothing to tint against */
/* Every raised surface gets a top highlight instead of relying on shadow */
--highlight-top: inset 0 1px 0 0 oklch(1 0 0 / 0.06);
--elevation-1: var(--highlight-top),
0 1px 2px -1px oklch(0 0 0 / 0.40);
--elevation-2: var(--highlight-top),
0 4px 12px -2px oklch(0 0 0 / 0.50),
0 0 0 1px oklch(1 0 0 / 0.06);
--elevation-3: var(--highlight-top),
0 16px 40px -8px oklch(0 0 0 / 0.60),
0 0 0 1px oklch(1 0 0 / 0.08);
--elevation-4: var(--highlight-top),
0 32px 72px -16px oklch(0 0 0 / 0.70),
0 0 0 1px oklch(1 0 0 / 0.10);
}
The top highlight does most of the work. A 1px rgba(255,255,255,0.06) inset line along the top edge suggests a light source above, which does more in dark mode than any shadow. It's also why dark surfaces get lighter as they rise: --surface 0.205 → --surface-raised 0.240. Both say "closer to the light."
Borders#
Three levels of meaning, all at one pixel width.
--border-subtle /* dividers WITHIN a surface: table rows, list items, sections */
--border /* the edge OF a surface: cards, inputs, panels */
--border-strong /* emphasis: hovered input, selected item, active state */
All 1px. Borders here vary in contrast, not width. A 2px border is a smell. It's almost always making up for too little contrast at 1px, or for missing hierarchy somewhere else.
Edge, divider, emphasis
Rules#
- Never a border and a shadow on the same resting element. The exception is floating surfaces (e2+), where the ring is part of the shadow token.
- Dividers are
--border-subtle, edges are--border. The line between two table rows is lighter than the line around the table. If they match, the table dissolves into a grid. - Hairlines are full-bleed inside cards. A divider between list rows spans the full card width with negative margin (
-mx-6against a 24px inset), not the padded width. A divider that stops short of the card edge looks like a mistake, because the eye reads the card edge as the container. - A
1pxline at high DPI is still1pxCSS. Don't use0.5px. It renders inconsistently across engines and disappears on 1x displays. If a line looks too heavy, lower its contrast instead of thinning it. - The bottom edge is optically heavier. In light mode you may darken a card's bottom border one step (from
--borderto between--borderand--border-strong) to suggest a light source above. It's optional, and only for the payment card and the hero balance card. Never in tables.
Rule lines in statements#
Statements have their own rule lines with accounting meaning: a single rule above a subtotal, a double rule under a grand total. Those rules live in numerics because they carry meaning.
Focus rings#
Focus rings aren't part of the elevation system. Always use:
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit; /* outline follows border-radius automatically */
}
:focus-visible, never:focus. Clicking a button with a mouse shouldn't ring it.outline, neverbox-shadow. An ancestor'soverflow: hiddennever clips an outline, and it composites cheaply.outline-offset: 2pxso the ring never sits on the element's own border and never obscures a value.- On a dark or accent-filled element, add an inner white ring for contrast on both sides:
outline-color: var(--accent); box-shadow: 0 0 0 2px var(--surface); - Never remove a focus outline without a visible replacement.
Backdrops#
Dialogs and sheets get a scrim, not a blur.
--scrim: oklch(0.21 0.02 265 / 0.40); /* light mode */
--scrim-dark: oklch(0 0 0 / 0.60);
No backdrop-filter: blur() on full-screen surfaces. It's the most expensive thing you can put on a scrim. It forces a full-viewport readback and repaint every frame, and on a mid-range Android phone or an Intel MacBook it turns a 240ms dialog entrance into a stutter. A tinted scrim reads as "behind" just as well.
Blur is allowed:
- On small, static surfaces (a sticky toolbar ≤ 64px tall) with
backdrop-filter: blur(12px), not animated. - As a short one-shot transition mask at ≤ 2px (Emil's crossfade trick; see motion).
- Never animated. Never > 8px on anything moving. Never on a full-screen surface.
Scroll shadows#
When content scrolls under a sticky header, the header gets a shadow. It's the one case where elevation is dynamic, and it's useful: it tells you there's content above.
Use IntersectionObserver on a 1px sentinel above the header. Never a scroll listener. That's a main-thread animation driven by scroll events, which is forbidden.
.sticky-header { box-shadow: none; transition: box-shadow 140ms var(--ease-standard); }
.sticky-header[data-scrolled] { box-shadow: var(--elevation-1); }
Use the same technique for horizontal scroll shadows on a wide table, where a left or right fade signals more columns. Use a mask-image gradient or a small absolutely positioned gradient element. The sentinel sets a data attribute and CSS does the rest.
Anti-patterns#
| Don't | Why | Do |
|---|---|---|
box-shadow on a resting card |
Everything floats, nothing floats | border: 1px solid var(--border) |
| Single-layer shadow | Reads as a default, not a decision | ≥ 2 layers, ambient + direct |
| Pure black shadow | Muddy over tinted neutrals | Tint with the ink hue |
Animating box-shadow |
Paint on every frame, on a large surface | Cross-fade two stacked pseudo-elements' opacity, or accept a 140ms paint on a small element |
backdrop-filter on a scrim |
Full-viewport readback per frame | Tinted scrim |
2px borders |
Compensating for a contrast problem | Raise contrast at 1px |
| Elevation to show importance | Elevation means z-position, not priority | Size, weight, and position show importance |
will-change: box-shadow sitting permanently |
Holds a compositor layer forever | Add on interaction start and remove on end, or skip it |