---
title: "Elevation, borders & shadows"
description: "Lopifi's most opinionated decision: At rest, a surface is defined by a hairline. A shadow means the thing is actually floating."
canonical: "https://lopifi.com/docs/foundations/elevation-borders-shadows"
---

# 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.

Source: https://lopifi.com/docs/foundations/elevation-borders-shadows
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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.

<!-- plate:elevation-tiers -->

---

## 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.

<!-- plate:elevation-layers -->

### The scale

```css
--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**.

```css
[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.

```css
--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.

<!-- plate:elevation-borders -->

### Rules

1. **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.
2. **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.
3. **Hairlines are full-bleed inside cards.** A divider between list rows spans the full card width with 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.
4. **A `1px` line at high DPI is still `1px` CSS.** Don't use `0.5px`. It renders inconsistently across engines and disappears on 1x displays. If a line looks too heavy, lower its contrast instead of thinning it.
5. **The bottom edge is optically heavier.** In light mode you may darken a card's bottom border one step (from `--border` to between `--border` and `--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](https://lopifi.com/md/docs/foundations/numerics#rule-lines) because they carry meaning.

---

## Focus rings

Focus rings aren't part of the elevation system. Always use:

```css
: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`, never `box-shadow`. An ancestor's `overflow: hidden` never clips an outline, 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.**

---

## Backdrops

Dialogs and sheets get a scrim, not a blur.

```css
--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](https://lopifi.com/md/docs/motion/principles#blur-as-a-transition-mask)).
- 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.

```css
.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 |
