Visual Hierarchy
Decide what an element IS before you decide what colour it is. Role determines elevation; elevation is then expressed with lightness, contrast, borders and space — and contrast that is not carrying meaning is noise.
Also called Layering, Depth, Surface Hierarchy — in this system all of them are Visual Hierarchy.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
One class, two meanings, neither of them the intended one. The wash is 30% of the page colour: on the page it composites to the page and the tile disappears; inside the panel it drags 30% of the way back down and reads as a hole punched in it.
Boxing depth
A filled, bordered container is a claim that what is inside is a distinct object. Make the claim twice and it is weaker, not stronger.
One box. The card is a thing on the page — the common, correct case.
The four channels
Lightness, a marker, type weight and foreground colour. Fill is the one everyone reaches for and the only one that depends on what is underneath it.
Fill alone. It works here, and it stops working the moment this list sits on a lighter surface or the user has a low-contrast display.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Four levels, each earned by what the element is. The search field is the highest not because it is important but because it is interactive.
- Page--ds-canvas
The ground. On a black theme it sits one rung up from the floor, so a well still has one step to drop into.
- Panel--ds-surface
A region of the page. One step.
- Card--ds-surface-raised
An object on the panel. Two steps.
- Control--ds-field
Interactive, so it stands above the card it sits in — never below.
- Boundary--ds-border-subtle
Alpha, so one value composes over every level.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Surfaces | ||
| --ds-canvas | — | The page. The floor of the ramp. |
| --ds-surface | — | A panel or region on the page |
| --ds-surface-raised | — | A card — an object on a panel |
| --ds-surface-overlay | — | Drawers, dialogs, menus — anything over a scrim |
| --ds-surface-inset | — | Non-interactive wells only. If it takes focus it is not a well. |
| Controls | ||
| --ds-field | — | Any control the user can act on |
| Interaction layers | ||
| --ds-layer-hover | — | Alpha, so it composes over any level |
| --ds-layer-selected | — | Selection tint — pair it with a second channel |
| Boundaries | ||
| --ds-border-subtle | — | Grouping without a fill |
| Foreground | ||
| --ds-fg-secondary | — | De-emphasis with a 4.5:1 floor |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 / --space-4 | — | Grouping by proximity — try this before a box |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- A boundary that carries meaning — a card edge, a selected marker, a focus ring — needs 3:1 against its adjacent surface. A boundary at 1.2:1 is a decision that was never rendered.
- De-emphasis has a floor. Secondary and muted text still need 4.5:1; "quieter" must never resolve to "harder to read".
- Surface steps themselves have no contrast minimum, because they are not information on their own. That is precisely why a surface step must never be the only thing expressing a state.
- Check the composite, not the token. A muted foreground certified against the page can fail against an overlay, which is a lighter ground.
Keyboard
| Tab order | Must follow the visual hierarchy. If the eye goes heading → body → action, focus goes the same way. |
| Focus ring | Is a hierarchy signal and outranks hover. It must be visible on every level of the ramp, which means an alpha ring or a paired light/dark value. |
| Arrow keys | Within a list, moving focus must move the visible emphasis with it — focus that leaves no trace is a hierarchy that exists only for the mouse. |
Screen readers
- Elevation is entirely invisible without sight. Every meaning carried by a surface step — this is a dialog, this is selected, this is grouped — needs a role, a state or a landmark carrying the same claim.
- Boxing that exists only for looks adds nothing to the audio experience but the nesting is often announced anyway. Fewer, more meaningful containers read better in both modes.
- Announce state rather than relying on emphasis: "selected", "expanded", "3 of 12".
Focus & touch
- Focus order is the hierarchy expressed for people who are not looking at it. A layout that reads correctly but tabs in a scrambled order has a hierarchy that only exists visually — the structure was implied by position rather than built.
- Hierarchy has to survive the absence of hover. On touch there is no intermediate state between rest and commit, so any hierarchy whose only expression is a hover treatment simply does not exist for half the users. Selected and pressed carry the whole load.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-current | The selected item | Selection expressed only as a fill is invisible to assistive tech. This is the programmatic half of the same statement. |
| Heading levels | Visual hierarchy | Font size is not a heading level. A page whose hierarchy is purely visual has no hierarchy for a screen reader. |
| Landmarks / fieldset | Grouped regions | A group made from spacing alone exists only for sighted users. If the grouping matters, give it a semantic container too. |
| aria-disabled | De-emphasised controls | Disabled is the one state permitted to drop below 4.5:1, and only because it is also announced. |
Example usage
The check worth automating: composite a translucent surface against its real parent and assert it moved the intended direction.
1type RGB = [number, number, number]23const hexToRgb = (hex: string): RGB =>4 [0, 2, 4].map((i) => parseInt(hex.replace('#', '').slice(i, i + 2), 16)) as RGB56/** What a translucent class ACTUALLY paints, given what is beneath it. */7const composite = (base: RGB, top: RGB, alpha: number): RGB =>8 base.map((b, i) => alpha * top[i] + (1 - alpha) * b) as RGB910const relLuminance = ([r, g, b]: RGB) => {11 const ch = (v: number) => {12 const s = v / 25513 return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.414 }15 return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)16}1718/**19 * In a dark theme a stacked surface must be LIGHTER than its parent.20 * Pass the parent you will really be rendered on — not the page.21 */22const raisesCorrectly = (parent: string, wash: string, alpha: number) =>23 relLuminance(composite(hexToRgb(parent), hexToRgb(wash), alpha)) >24 relLuminance(hexToRgb(parent))2526raisesCorrectly('#101010', '#101010', 0.3) // false — on the page it vanishes27raisesCorrectly('#303030', '#101010', 0.3) // false — inside a drawer, a hole2829// Which is the whole argument for opaque steps:30// a rung is absolute, a wash is a function of its parent.CSS
Surfaces are opaque and absolute. Layers and borders are alpha and relative.
/* SURFACES — opaque, so the level survives being reused elsewhere. */
.panel { background: var(--ds-surface); }
.card { background: var(--ds-surface-raised); }
.drawer { background: var(--ds-surface-overlay); }
.control { background: var(--ds-field); }
/* LAYERS — alpha, so one value composes over every surface above. */
.row:hover { background: var(--ds-layer-hover); }
.row[aria-current] { background: var(--ds-layer-selected); }
/* ...and selection never rests on the tint alone. */
.row[aria-current] {
font-weight: 600;
color: var(--ds-accent-text);
}
.row[aria-current]::before {
content: '';
position: absolute;
inset-block: 25%;
inset-inline-start: 0;
inline-size: 2px;
background: var(--ds-accent);
}
/* BOUNDARIES — alpha, so one token stays visible on every level. */
.card, .drawer, .panel { border: 1px solid var(--ds-border-subtle); }
/* The bug this prevents: an opaque border equal to its parent's fill.
border-color: #1f1f1f on a #1f1f1f panel measures 1.00:1. */Component API
Role → level
| Prop | Type | Default | Description |
|---|---|---|---|
| Page | --ds-canvas | — | The ground. Almost nothing goes below it — on a black theme there is one rung of headroom and --ds-sunken is already using it. |
| Panel / region | --ds-surface | — | A division of the page. Often needs no fill at all — spacing may be enough. |
| Card / object | --ds-surface-raised | — | Something separable that the user thinks of as a unit. |
| Control | --ds-field | — | Anything actionable. Goes above its container regardless of how deep that container is. |
| Overlay | --ds-surface-overlay | — | Drawer, dialog, menu. Top of the ramp, plus a scrim. |
| Well | --ds-surface-inset | — | Non-interactive only: code blocks, table headers, meter tracks. Below the container it sits in, not below the page — on black the page has no room under it. If it takes focus, it is a control. |
| Hover / selected | --ds-layer-* | — | Alpha over whatever the row already is. Never an opaque value. |
Professional tips
- The fastest review question for any surface is "what is this?" — if the answer is a role, the value follows. If the answer is a colour, the decision has not been made yet.
- Most "this looks off but I cannot say why" reactions are a hierarchy violation, and naming the level that is wrong turns a taste argument into a one-line fix.
- Try deleting the box before adjusting its colour. A surprising number of surface problems are really boxing problems, and the fix removes code.
- When a component renders in two places — a card and a drawer, a page and a modal — its surface must be a function of the parent, not a constant. That is an argument for a class per level, not a value per component.
- Interactivity outranks depth. A control inside a well still goes up, because "you can act on this" is a stronger claim than "this is recessed".
Performance
- Opaque surfaces are cheaper than stacked translucent ones: every alpha layer is another blend the compositor performs on every paint.
- Large blurred scrims (backdrop-filter) are the expensive part of an overlay, not the panel. One scrim per view; do not nest them.
- Animating background-color forces paint. Animate opacity or transform where the effect allows it.
Common mistakes
- Painting a surface with a translucent wash of the page colour, so the same class reads as raised on the page and as a hole inside a panel.
- Reaching for a darker value to make something recede, in a theme where receding is what the page already does.
- Expressing selection with a fill alone, then discovering it is indistinguishable from hover once the list moves to a different surface.
- Bordering in an opaque colour that happens to match the current parent, which works until the component is reused one level up.
- Adding a box for every group, until the screen is boxes and the actual content is four levels of padding away.
- Treating a token ladder as the principle. The ladder is one implementation of it for one theme; the reasoning is what transfers.
Real-world recommendations
- Found in production: a drawer correctly raised one step, with its status tiles, phase rail and every card inside it painted with a 30% wash of the page colour. The panel was right and everything in it was inverted — including the "in progress" row, which came out darker than the idle rows around it.
- The same audit found three borders at 1.00:1, all of them the correct token for the surface the component was originally written on, and all of them invisible after it moved.
- A component rendered in two containers is the reliable way to catch this. If a class only looks right in one of them, it was never a level — it was a coincidence.
- Hierarchy bugs survive review because each individual value looks defensible in isolation. They are found by composing the stack, not by reading the diff.