Skip to content

04 — FOUNDATIONS

Relief

How far this system lets a surface leave the page, as one dial. It ships at zero, which is the flat, hairline-ruled page the library has always drawn — so nothing here costs you anything until you ask for it. Turn it up and shadows, lit edges and sheen arrive together, on a published list of surfaces and nowhere else.

ZERO IS NOT A DEFAULT, IT IS ARITHMETIC

Every term in every rung is multiplied by its own dial. At zero each offset computes to 0px and each ink to transparent, so the whole family resolves to a shadow that draws nothing at all. That is not a promise anybody has to keep — it is what the numbers do, which is why check-theme can assert it outright rather than compare screenshots.

So adopting this library, or upgrading into a version that has relief in it, moves nothing. You opt in, in one line, when you want to.

/* depth, everywhere, in one line */
:root {
  --ds-relief: 1;
}
WHAT THE DIAL MOVESA PUBLISHED LIST, AND NOTHING ELSE

The rungs are each read by a stated set of surfaces. Everything else in the library stays flat at every setting — a lifted chip in a row of twenty is noise, not depth.

CONTROL · WELL · RAISED — at this page's own setting

RAISED

A surface that leaves the page

tools/check-theme.mjs drives --ds-relief at the root, reads the computed shadow off every control in this box, and fails if one of them does not change — then fails again if removing the override does not put flat back.

THE VOCABULARY38 TOKENS · 9 KNOBS

THE DIAL

--ds-relief0

How far this site lets a surface leave the page — ONE decision for the whole document, and the only one most consumers ever make. It scales every family at once, so turning it moves the lit edge, the sheen and the cast shadow together. Zero is flat and is what ships, so a consumer who never heard of it gets the page this library has always drawn. One is the depth the reference was designed at. There is no ceiling, but past about two a page stops reading as paper and starts reading as a stack of cards.

A FAMILY

--ds-lift1

The THIRD shadow in the list — the one cast below the box, `0 2px 8px -2px` at one unit. The only family that scales its geometry as well as its ink, because a thing that is higher casts further as well as darker. On ink it is by far the loudest of the three, 52% against the rim at 24% and the sheen at 9%, which is why turning it feels like turning the master: it IS most of what the master does. On paper the balance inverts. Like the other three it is a SHARE of the master rather than a value of its own: 1 takes all of the dial, 0 takes none, 2 takes double.

--ds-rim1

The FIRST shadow in the list — an inset hairline along the top edge, `inset 0 1px 0 0`, and its geometry never moves. Light catching the top edge of something standing up. THE TWO GROUNDS NEED OPPOSITE MIXES, which is the whole reason these are separate dials and not one: on ink the rim is 24% and the cast shadow 52%; on paper the rim is 55% and the shadow 22%. It is what makes depth read at all on a dark ground, where a shadow under a card is a dark shape on a dark page and says nothing.

--ds-gloss1

The SECOND shadow in the list — an inset wash falling down the fill, `inset 0 14px 14px -14px`, its geometry fixed like the rim's. A sheen washing down a filled surface, quietest of the three on ink at 9% and four times that on paper. Turn it up on its own for a glassy look; leave it at zero for shadows with no shine in them at all.

THE ONE THAT MOVES

--ds-sheen1

A band of light crossing a surface when you reach for it. The one relief family that moves, and the only one that is opt-in per element rather than automatic: it arrives with the `sheen` class and nowhere else. No slider, because it follows the dial.

--ds-sheen-opacitycalc(var(--ds-sheen-on) * 0.14)

How bright the band is, at the dial in force. There is no ink beside it: the band is `currentcolor`, so a light gleam lands on a dark fill and a dark one on cream, with nothing to configure.

--ds-sheen-duration900ms

How long the crossing takes. Slower than the ripple's blade at 640ms, on purpose — a press is answered and an invitation is offered, and an invitation that crosses at the speed of an answer reads as a twitch.

OVER THE WHOLE PAGE

--ds-grain0

Noise over the page ground, and the one family here that is NOT depth. Lift, rim and gloss are three ways a surface says how far it stands off the page; this says whether the page is printed or screen-flat, which is true of a document whether or not anything on it is raised. So it is a dial of its own, independent of the master, and it ships at nothing. At the strengths worth using you do not see it — you notice its absence when it is switched off. A theme that wants the two coupled writes `--ds-grain: var(--ds-relief)` and gets the old behaviour back in one line.

--ds-grain-imageurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E")

ON PAPER — url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeColorMatrix type='matrix' values='0.08 0 0 0 0 0 0.08 0 0 0 0 0 0.08 0 0 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E")

The texture itself, as a token, so a theme wanting paper fibre or a halftone states its own and base.css does not change. Three hundred-odd bytes of inline SVG: no request, no asset, nothing to host.

--ds-grain-size160px

The tile. Larger reads as cloud, smaller as static.

--ds-grain-opacitycalc(var(--ds-grain) * 0.055)

ON PAPER — calc(var(--ds-grain) * 0.08)

How strong, at the dial in force. Zero at the shipped default, and the layer then paints nothing at all — measured pixel-identical against the same page with the rule deleted.

--ds-grain-blendnormal

How the noise meets the ground. `normal` ships, and `overlay` is the better-looking answer — it darkens what is dark and lightens what is light rather than laying one wash over both. It is not the default because any blend other than `normal` forces the body to isolate, which changes how everything inside it composites at ANY opacity, zero included. Measured: with overlay, every viewport of two site pages differed from having no layer at all, while the layer was painting nothing.

WHAT A SHADOW IS

--ds-lift-drop2px

How far a shadow falls at one unit of lift. Take it to zero and pair it with a wide blur for an omnidirectional glow, which is how depth reads on near-black.

--ds-lift-blur8px

How soft the edge is. At zero you get a hard offset block, which suits a hairline-ruled design better than it has any right to.

--ds-lift-spread-2px

How much narrower than its box a shadow is. Negative on purpose: at zero a soft shadow haloes evenly on all four sides and the card reads as printed rather than as lifted.

--ds-gloss-depth14px

How far down a fill the sheen washes before it is gone. Short is a highlight on the top edge; long is a gradient across the whole surface.

HOW FAR OFF THE PAGE

--ds-lift-well0.75

A field, a code block — pressed into the page rather than laid on it. The one rung that goes inward, and the one with no highlight: light catches the top edge of something standing up, and the top edge of a recess is the part in shadow.

--ds-lift-control1

A button, resting on the page. The unit the other rungs are measured against.

--ds-lift-raised2

A card: the case plate, the CTA slab, the featured offer.

--ds-lift-floating4

Something that has left the page — a dropdown panel, the dock, the plate control lying on a photograph.

--ds-lift-overlay8

A modal, over everything. The top of the ladder.

PER GROUND

--ds-lift-oncalc(var(--ds-relief) * var(--ds-lift))

How much lift is on, once the master has had its say. The multiplication lives here rather than at twenty-one call sites.

--ds-rim-oncalc(var(--ds-relief) * var(--ds-rim))

The same for the lit edge.

--ds-gloss-oncalc(var(--ds-relief) * var(--ds-gloss))

The same for the sheen.

--ds-sheen-oncalc(var(--ds-relief) * var(--ds-sheen))

The same for the blade of light. Sheen IS depth, so unlike grain it stays a share of the master.

--ds-lift-mixcalc(var(--ds-lift-on) * 52%)

ON PAPER — calc(var(--ds-lift-on) * 22%)

How strong a shadow is at one unit of lift, as a percentage the dial multiplies. Per ground, and by a wide margin: on near-black a shadow has to be nearly opaque to be seen at all, and the same value on paper is a smear.

--ds-rim-mixcalc(var(--ds-rim-on) * 24%)

ON PAPER — calc(var(--ds-rim-on) * 55%)

How strong the lit edge is. Far higher on paper, where the highlight has to stand out from a surface that is already near-white.

--ds-gloss-mixcalc(var(--ds-gloss-on) * 9%)

ON PAPER — calc(var(--ds-gloss-on) * 40%)

How strong the sheen is.

--ds-lift-inkcolor-mix(in srgb, #060c10 var(--ds-lift-mix), transparent)

ON PAPER — color-mix(in srgb, var(--ds-ink) var(--ds-lift-mix), transparent)

What a shadow is made of. Stated rather than derived on a dark ground — a shadow is the absence of light and no role in the system is darker than the ground itself. On paper it is the ink of the design; a neutral grey shadow over a warm ground reads as dirt.

--ds-rim-inkcolor-mix(in srgb, var(--ds-ink) var(--ds-rim-mix), transparent)

ON PAPER — color-mix(in srgb, var(--ds-ground) var(--ds-rim-mix), transparent)

What the lit edge is made of: the brightest thing in the palette. On a dark ground that is the ink; on paper the ink is the darkest thing there is and the brightest is the ground itself.

--ds-gloss-inkcolor-mix(in srgb, var(--ds-ink) var(--ds-gloss-mix), transparent)

ON PAPER — color-mix(in srgb, var(--ds-ground) var(--ds-gloss-mix), transparent)

What the sheen is made of. The same source as the rim, and for the same reason.

WHAT A COMPONENT READS

--ds-shadow-flat0 0 0 0 transparent

The zero rung, named. A component or a caller opts out with this rather than with `none` — `none` cannot appear inside a shadow list, so a rule that resets to it is a rule a focus ring can never be composed onto.

--ds-shadow-wellvar(--ds-lift-geo-well) var(--ds-lift-ink)

Read by the text inputs, the select, the affixed field and the code block.

--ds-shadow-controlvar(--ds-rim-geo) var(--ds-rim-ink), var(--ds-gloss-geo) var(--ds-gloss-ink), var(--ds-lift-geo-control) var(--ds-lift-ink)

Read by the button. It takes the raised rung on hover, so a door you have reached for comes further out of the page.

--ds-shadow-raisedvar(--ds-rim-geo) var(--ds-rim-ink), var(--ds-gloss-geo) var(--ds-gloss-ink), var(--ds-lift-geo-raised) var(--ds-lift-ink)

Read by the case plate, the CTA slab, the featured offer card and the sticky site header.

--ds-shadow-floatingvar(--ds-rim-geo) var(--ds-rim-ink), var(--ds-gloss-geo) var(--ds-gloss-ink), var(--ds-lift-geo-floating) var(--ds-lift-ink)

Read by the dropdown panel — which the megamenu composes with rather than repeating — and by the plate control on a clip.

--ds-shadow-floating-upvar(--ds-rim-geo) var(--ds-rim-ink), var(--ds-gloss-geo) var(--ds-gloss-ink), var(--ds-lift-geo-floating-up) var(--ds-lift-ink)

The same rung falling the other way, read by the dock. A bar pinned to the bottom of the screen casts upward or it casts off the screen, and that is a second physical situation rather than a negated first one.

--ds-shadow-overlayvar(--ds-rim-geo) var(--ds-rim-ink), var(--ds-gloss-geo) var(--ds-gloss-ink), var(--ds-lift-geo-overlay) var(--ds-lift-ink)

Read by the modal card, and by nothing else.

Those values are read out of the theme files at build time, so this table cannot drift from them. Every rung is also a utility class — shadow-raised, shadow-well, shadow-overlay — for a page lifting a box the library has no component for. They cost nothing until relief is turned up, which is what makes them safe to reach for.

IN WHAT SHAPE

A shadow is not one look. Three numbers decide what a shadow in your theme is, and the rungs scale that one shape rather than each inventing its own. Four recipes, all reachable from the sliders on the theme builder:

SOFT

the shipped shape. A cast shadow with a negative spread, so it stays narrower than its box.

--ds-lift-drop: 2px;
--ds-lift-blur: 8px;
--ds-lift-spread: -2px;

HARD

no blur at all. An offset block, which suits a hairline-ruled design better than it has any right to.

--ds-lift-drop: 3px;
--ds-lift-blur: 0px;
--ds-lift-spread: 0px;

GLOW

no drop. Light leaking evenly out of the box, which is how depth reads on near-black.

--ds-lift-drop: 0px;
--ds-lift-blur: 18px;
--ds-lift-spread: -6px;

HALO

no blur and no drop. A second edge, stated — closer to a ring than to a shadow.

--ds-lift-drop: 0px;
--ds-lift-blur: 0px;
--ds-lift-spread: 3px;

A HIGHLIGHT IS MADE OF WHATEVER IS BRIGHTEST, AND THAT SWAPS

Half this vocabulary is geometry and lives once. The other half is colour and strength, and both are answers to which ground you are on — so both live in the theme files, twice, with different values.

The interesting half is the highlights. A rim and a sheen are light, so they are made of the brightest thing in the palette. On a dark ground that is --ds-ink; on paper the ink is the darkest thing there is and the brightest is --ds-ground itself. Deriving both from the ink — which is what reads naturally, and what a first draft does — gives a light page dark "highlights": a lit edge drawn in shadow, which looks like a rendering fault rather than a bug.

The strengths are re-measured rather than flipped, exactly as the ink ladder was. A shadow strong enough to read on near-black is a smudge on paper, and a rim faint enough to sit under dark ink is invisible against a surface already near-white.

DEPTH BELONGS TO A THEME, SO A SUBTREE THAT WANTS ITS OWN TAKES A THEME

Setting --ds-relief on a plain <div> does nothing, and that is the same rule the corner ladder has: a custom property is substituted where it is declared, so the rungs resolve against the root and the resolved shadow is what inherits down.

What re-derives them is a theme. Give the subtree data-theme and its own dial, and it lifts — in that ground's own inks, inside a page that is still flat:

<section data-theme="portfolio-light" style="--ds-relief: 1">
  <button class="btn btn-primary">SEND</button>
</section>

A LIGHT SECTION, LIFTED, INSIDE A FLAT PAGE

ONE FAMILY AT A TIME

The dial is a convenience, not a cage. Each family defaults to it and any of them can be set on its own — shadows with no shine in them, or a lit edge and nothing else. Same move as taking a corner off the radius ladder.

/* or one family at a time — shadows, no shine */
:root {
  --ds-lift: 1;
  --ds-gloss: 0;
}

TWO FAMILIES HAVE NO SLIDER, AND BOTH FOLLOW THE DIAL

Sheen is opt-in per element — it arrives with a class, on a surface you choose — and grain is the opposite: a single layer over the whole document, fixed to the screen rather than to the page, so the texture belongs to the glass and not to what is printed on it. Neither is on the builder, because a slider is held to moving something you can see on the page it is drawn on, and neither of these moves a box.

The grain sits above everything except a modal — a dialog is in the top layer, which no z-index reaches, so it is correctly the one surface the texture does not cover. It is off in print, where it is toner spent on a grey cast, and off under prefers-contrast: more, because a reader who asked for more contrast has asked for less of exactly this.

And it costs nothing when it is off. Measured: a page with the rule and the same page with it deleted are byte-identical screenshots at 2× device scale, and the difference in median load time over twelve runs each way was smaller than the run-to-run spread of the same configuration.

TURN THEM AND WATCH

Every knob is on the theme builder as sliders, beside the colour roles and the corners, and the whole book gains depth as you drag. Relief is stored once per theme rather than once per ground — the light and dark palettes of one theme are two answers about light, and how far a card stands off the page is not a question about light.

A SHADOW IS ALWAYS A TOKEN, AND SO IS NO SHADOW

check-css refuses a box-shadow in the four layers that does not read a token, and refuses box-shadow: none along with it — none is the one value that cannot appear inside a shadow list, so a rule that resets to it is a rule a focus ring can never be composed onto. --ds-shadow-flat is the same statement in a form that composes.

That matters more here than it looks. Every focus ring in this library is a box-shadow, drawn in @layer base, which every component layer outranks — so a component that gains a resting shadow and writes its focus rule as the ring alone drops back onto the page the moment it is focused, and one with no focus rule of its own loses the ring entirely. A second check holds both halves. It was written after finding a control here that had already lost its ring that way.