Elevation, borders & shadows
The single most opinionated decision in Lopifi:
At rest, a surface is defined by a hairline. A shadow means the thing is actually floating.
Almost every dashboard gets this backwards — cards get shadow-md for decoration, and then a dropdown that genuinely floats has nowhere to go. Once everything is elevated, nothing is.
The two-tier model
Tier 1 — Structural (no shadow)
Anything that scrolls with the page and occupies 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, full stop. Depth comes from the surface being lighter than the canvas (light mode: white on #fafafb), not from a shadow.
Tier 2 — Floating (shadow)
Anything that is temporarily above the document and would be occluded if it weren't:
- 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 far above the page they are.
Shadow construction
Real shadows are two lights: a broad ambient occlusion and a tighter directional cast. One-layer shadows look like a Photoshop default because they are one.
Every Lopifi shadow is ≥ 2 layers, obeying:
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
And critically:
Shadows are tinted, never pure black.
oklch(0.21 0.02 265 / α)— the neutral ink hue at low chroma. A pure-black shadow over a cool-neutral surface renders muddy brown-grey; a hue-matched shadow renders as shade.
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 against a light background.
Dark mode elevation
Shadows barely read on a dark canvas — a black shadow on a near-black background is invisible. Dark mode uses a different physical 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 is the whole trick. A 1px rgba(255,255,255,0.06) inset line along the top edge simulates a light source above, and it does more work in dark mode than any shadow can. It is also why dark surfaces get lighter as they rise — --surface 0.205 → --surface-raised 0.240. Both mechanisms say "closer to the light".
Borders
Three weights of meaning, one weight of pixels.
--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. Border craft in this system is about contrast, not width. A 2px border is a design smell — it's almost always compensating for insufficient contrast at 1px, or for a missing hierarchy elsewhere.
Rules
- Never a border and a shadow at rest on the same element — except 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 must be lighter than the line around the table. If they're equal, the table dissolves into a grid. - Hairlines are full-bleed inside cards. A divider between list rows spans the full card width via 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, not the padding box. - A
1pxline at high DPI is still1pxCSS. Do not use0.5px— it renders inconsistently across engines and disappears entirely on 1x displays. If a line looks too heavy, lower its contrast, don't thin it. - The bottom edge is optically heavier. In light mode you may darken a card's bottom border one step (
--border→ between--borderand--border-strong) to simulate a light source above. Optional; use it on the payment card and the hero balance card only, where it earns its keep. Never in tables.
Rule lines in statements
Statements use a distinct border vocabulary carrying accounting meaning — single rule above a subtotal, double rule beneath a grand total. That grammar lives in numerics because it's semantic, not decorative.
Focus rings
Not part of the elevation system. Always:
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit; /* outline follows border-radius automatically */
}
:focus-visible, never:focus— a mouse click on a button should not ring it.outline, neverbox-shadow— outline is never clipped by an ancestor'soverflow: hidden, 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. Non-negotiable.
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 is the single 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 or an Intel MacBook it turns a 240ms dialog entrance into a stutter. A tinted scrim reads as "behind" just as well.
Where blur is permitted:
- Small, static surfaces (a sticky toolbar ≤ 64px tall) with
backdrop-filter: blur(12px)not animated. - Short one-shot transition masking 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 earns a shadow — this is the one case where elevation is dynamic, and it's genuinely useful because it answers "is there content above?".
Implement with IntersectionObserver on a 1px sentinel above the header. Never a scroll listener — that's a main-thread animation driven by scroll events, which is explicitly forbidden.
.sticky-header { box-shadow: none; transition: box-shadow 140ms var(--ease-standard); }
.sticky-header[data-scrolled] { box-shadow: var(--elevation-1); }
Same technique for horizontal scroll shadows on a wide table: a left/right fade indicating more columns. Use a mask-image gradient or a small absolutely-positioned gradient element — the sentinel drives a data attribute, 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, remove on end — or don't |