Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

on the page
Status
Ready
#101010 on #101010
vanishes into the ground
inside a raised panel
Status
Ready
#262626 on #303030
reads as a hole

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.

Total audio4h 12m across 18 lessons

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.

Overview
Projects
Billing
Settings

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.

Projects
Restthe baseline the others are read against
Projects
Hovera hint, not a commitment
Projects
Selectedmust outrank hover, and not by a hair
Projects
Selected + hoverstill legible as selected
Projects
Disabledthe one state allowed to lose contrast

Anatomy

Every part, every measurement, and the reason it is that number.

Course
Lesson 4 — Compound interest
Search lessons…

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.

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

  2. Panel--ds-surface

    A region of the page. One step.

  3. Card--ds-surface-raised

    An object on the panel. Two steps.

  4. Control--ds-field

    Interactive, so it stands above the card it sits in — never below.

  5. Boundary--ds-border-subtle

    Alpha, so one value composes over every level.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-3 / --space-4—Grouping by proximity — try this before a box

Do

Name the role before you pick a valuePage, panel, card, control, overlay, selected state. The role determines the level; the level determines the token. Skipping to the token is how a control ends up darker than the card it sits in — nobody decided that, it just happened.
Use opaque steps for surfaces, alpha for layers and bordersA surface has an absolute position in the ramp and must keep it wherever the component is reused. Hover, selection, scrims and borders are relative by nature — they modify whatever they land on, which is exactly what alpha does.
Group with space before you group with a boxProximity is free, works at any contrast, and costs no level. A box is a strong claim; spend it on objects that are genuinely separable, not on every heading and its paragraph.
Give selection at least two channelsA tint alone is fragile: it shifts with whatever is beneath it, it vanishes on a dimmed screen, and it is invisible to a reader who cannot distinguish the hue. A marker bar, type weight or foreground colour costs nothing and makes the state survive.
Let contrast mean somethingIf a border, fill or shadow is not communicating depth, state or importance, it is decoration competing with the things that are. The test is whether you can say what it tells the reader.
Check the rendered value, not the class nameTranslucent classes resolve against their parent, so the same declaration reads correctly in one container and inverted in another. Composite it and look at the number before believing the name.

Don't

Do not paint a surface with a wash of the page colourIt is the single most common way the ramp gets inverted. On the page it lands slightly lighter and looks fine; inside a raised panel it lands most of the way back down to the page and reads as a hole punched in the panel.
Do not nest filled, bordered containers more than two deepThe ramp runs out, the padding eats the width, and by the third level the boxes are no longer telling the reader anything that spacing was not already telling them.
Do not let hover and selected land on the same weightThey answer different questions — "you are pointing at this" and "you are on this". When a hover in one part of a list matches a selection in another, the component has no state at all.
Do not draw a border in the same colour as what it sits onIt measures 1:1 and does nothing but consume a pixel. This is why boundaries are alpha values: one token stays visible across every level, instead of being correct at the level it was first written for.
Do not give the strongest treatment to the unselected itemsIt sounds impossible and it happens constantly, usually via a stray utility that outranks the intended one at equal specificity. Whatever the cause, the loudest thing on screen should not be the ten things the user is not looking at.
Do not box a single childA container around one element adds a level and a boundary to communicate a grouping that has nothing to group. It is the cheapest kind of visual noise to remove.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.11Non-text ContrastAA1.4.3Contrast (Minimum)AA1.4.1Use of ColorA1.3.1Info and RelationshipsA1.4.12Text SpacingAA

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 orderMust follow the visual hierarchy. If the eye goes heading → body → action, focus goes the same way.
Focus ringIs 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 keysWithin 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.
AttributeApplied toNotes
aria-currentThe selected itemSelection expressed only as a fill is invisible to assistive tech. This is the programmatic half of the same statement.
Heading levelsVisual hierarchyFont size is not a heading level. A page whose hierarchy is purely visual has no hierarchy for a screen reader.
Landmarks / fieldsetGrouped regionsA group made from spacing alone exists only for sighted users. If the grouping matters, give it a semantic container too.
aria-disabledDe-emphasised controlsDisabled is the one state permitted to drop below 4.5:1, and only because it is also announced.

Code

Example usage

The check worth automating: composite a translucent surface against its real parent and assert it moved the intended direction.

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

css
/* 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

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

Notes

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.