Skip to content

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

  1. 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.
  2. 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.
  3. Hairlines are full-bleed inside cards. A divider between list rows spans the full card width via negative margin (-mx-6 against 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.
  4. A 1px line at high DPI is still 1px CSS. Do not use 0.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.
  5. The bottom edge is optically heavier. In light mode you may darken a card's bottom border one step (--border → between --border and --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, never box-shadow — outline is never clipped by an ancestor's overflow: hidden, and it composites cheaply.
  • outline-offset: 2px so 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

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.