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

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

---

# 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

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

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

```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`. 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](https://lopifi.com/md/docs/foundations/06-numerics#rule-lines) because it's semantic, not decorative.

---

## Focus rings

Not part of the elevation system. Always:

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

```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 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](https://lopifi.com/md/docs/motion/01-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 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.

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