# On Dark, Borders Beat Shadows

**SHOULD** · **ID:** `design-interface-dark-borders-over-shadows` · **Category:** design
**Source:** [interface-design](https://github.com/Dammyjay93/interface-design)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-interface-dark-borders-over-shadows

> SHOULD: On dark surfaces define elevation with a hairline low-opacity border plus one small step of surface lightness, not a drop shadow — a shadow works by darkening pixels behind the element and near-black has no luminance left to remove, so `shadow-lg` exists in the computed style and nowhere on screen.

Define elevation on dark surfaces with a hairline border and a surface lightness step, because a drop shadow has nothing left to darken

A drop shadow works by darkening the pixels behind an element. On a near-black background there is almost no luminance left to remove, so shadow-lg on a dark card produces an elevation that exists in the computed style and nowhere on the screen — and if the card fill matches the panel behind it, the edges disappear entirely. Definition on dark comes from two cheaper, more reliable levers: a hairline border (a low-opacity light stroke reads as a crisp edge at any ambient brightness) and one small step of surface lightness — each jump only a few percentage points, barely visible in isolation but unmistakable once stacked. This is distinct from two neighbours in this corpus. design-impeccable-dark-glow is about the opposite failure — a saturated chroma glow spilling off a dark card, which is loud but meaningless. content-impeccable-dark-mode-text-compensation is about the type on dark (weight and colour compensation), not the box around it. This principle is only about where the edge of the box comes from.

## Bad — do not do this

`design-interface-dark-borders-over-shadows-bad`

```tsx
/**
 * Bad: elevation expressed purely as a drop shadow on a dark surface. `shadow-lg`
 * paints a dark blur onto an already near-black background — the shadow is in the
 * CSS and in the computed style, and it is invisible on screen. The cards read as
 * one flat slab because their fill matches the panel behind them.
 */
const CARDS = [
  { title: 'Requests', value: '182k' },
  { title: 'Errors', value: '0.03%' },
  { title: 'p95', value: '128 ms' },
];

export function InterfaceDarkBordersOverShadowsBad() {
  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">Dark panel &middot; elevation = shadow-lg, no border</p>

      <div className="rounded-lg bg-zinc-950 p-4">
        <div className="grid grid-cols-3 gap-3">
          {CARDS.map((card) => (
            /* Same fill as the panel + a shadow that has nothing lighter to fall on */
            <div key={card.title} className="rounded-lg bg-zinc-950 p-3 shadow-lg">
              <p className="text-xs text-zinc-400">{card.title}</p>
              <p className="mt-1 text-lg font-semibold text-zinc-100">{card.value}</p>
            </div>
          ))}
        </div>
      </div>

      <p className="text-xs text-error">
        A shadow is dark pixels. On a dark background there is almost no luminance left to darken,
        so the card edges vanish and the three tiles dissolve into the panel. You cannot see where
        one card ends and the next begins &mdash; the elevation exists only in the stylesheet.
      </p>
    </div>
  );
}
```

## Good — do this

`design-interface-dark-borders-over-shadows-good`

```tsx
/**
 * Good: on dark, definition comes from a hairline border plus one step of surface
 * lightness. No shadow at all. Each card is unmistakably a separate object, and the
 * effect survives a bright room and a cheap panel, which a drop shadow does not.
 */
const CARDS = [
  { title: 'Requests', value: '182k' },
  { title: 'Errors', value: '0.03%' },
  { title: 'p95', value: '128 ms' },
];

export function InterfaceDarkBordersOverShadowsGood() {
  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">
        Dark panel &middot; elevation = hairline border + one surface step
      </p>

      <div className="rounded-lg bg-zinc-950 p-4">
        <div className="grid grid-cols-3 gap-3">
          {CARDS.map((card) => (
            /* One lightness step up from the panel, defined by a 1px border */
            <div
              key={card.title}
              className="rounded-lg border border-zinc-800 bg-zinc-900 p-3"
            >
              <p className="text-xs text-zinc-400">{card.title}</p>
              <p className="mt-1 text-lg font-semibold text-zinc-100">{card.value}</p>
            </div>
          ))}
        </div>
      </div>

      <p className="text-xs text-success">
        The border does the work the shadow could not: a crisp edge that is legible at any ambient
        brightness, at zero blur cost. The single lightness step (panel &rarr; card) supplies the
        depth, so the tiles are clearly above the panel without a pixel of shadow.
      </p>
    </div>
  );
}
```

## References

- [interface-design (Damola Akinleye)](https://github.com/Dammyjay93/interface-design)
- [Material Design: Dark theme](https://m2.material.io/design/color/dark-theme.html)
- [MDN: box-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/box-shadow)
