Skip to main content

Foundations

The values everything is measured against

Two layers. Primitives are literal values with no meaning. Semantic tokens give them a job. Components consume only the semantic layer, so re-theming is one file and never a search through every component.

Information: Read from the live stylesheet

Every swatch, ruler and contrast figure below is computed from the custom property as it currently resolves — in the theme you are viewing. Switch the theme and the numbers recalculate. Nothing on this page is a transcribed copy, so nothing on it can go stale.


Cascade layers

The override contract, and the reason there is not a single !important anywhere in Dessau DS. All of DDS is inside @layer; your product's CSS is not. Unlayered styles beat every layered style, regardless of specificity.

Layer order, weakest to strongest

  1. dds.reset the smallest sane floor
  2. dds.foundation primitive and semantic values
  3. dds.base element defaults, focus, motion, forced colours
  4. dds.typography type utilities and the reading measure
  5. dds.layout container, stack, cluster, grid, sidebar
  6. dds.components buttons, fields, cards, dialogs …
  7. dds.patterns compositions that solve a task
  8. dds.utilities single-purpose helpers
  9. your product unlayered — beats all of the above

What this buys you

A one-class selector in your stylesheet overrides a five-class selector in DDS. You never have to out-specify the design system, never have to write !important, and never have to care how specific a DDS rule happens to be. That is the whole point: specificity wars are what make a design system something to fight rather than to build on.

The order above is declared once, up front, in dds/dds.css — before any @import. Declaring it up front is what makes it independent of load order: a layer's position is fixed by that statement, not by when its file happens to arrive.

The one thing to watch

If you put your own CSS into a DDS layer, you give up the advantage — you are back to competing on specificity inside that layer. Add a layer after dds.utilities if you want layered product styles, or simply leave yours unlayered.

Layers do not change how !important works, and for important declarations the order is reversed: an important rule in an earlier layer beats an important rule in a later one. It is a rule worth knowing and never worth relying on.

Colour

One neutral, one interactive hue, one decorative accent, four status hues. The restraint is deliberate: when every actionable thing shares a single colour, that colour starts to mean “you can act on this”.

Surfaces

Four planes. In light mode default and raised are both white and separation comes from shadow; in dark mode shadow barely reads, so they differ in value instead. Same model, different mechanism per theme.

Text

Contrast is shown against surface-default at the 4.5:1 body-text threshold. text-disabled is exempt under WCAG 1.4.3 but is still kept legible enough to read what is unavailable.

Action

Emphasis comes from fill and border weight, not from hue, so the ordering survives greyscale and forced-colours. Destructive actions borrow the error hue — the one sanctioned overlap between action and status, because “delete” genuinely is both.

Status

Four hues, each distinct at every step. Information uses a cyan rather than the action indigo on purpose: “this is information” must never look like “this is a button”.

Status surfaces, with body text contrast:

Borders and focus

Shown at the 3:1 non-text threshold (WCAG 1.4.11). border-subtle is below it deliberately: it separates content that layout already separates, so it carries no information on its own. Everything else must clear it.

Accent

Five numbered slots, decorative only. An accent tells one category from another — a chart series, a tag, an avatar. It never says “this succeeded” and never says “you can act on this”: those are the status and action colours, and they are the ones a reader has learnt. Two of the five share a ramp with a status hue, which is why that rule is written down rather than assumed.

And the tint each one pairs with. No badge here on purpose: every accent sits on a different tint, so the number that matters is the accent’s own label on its own tint — which is what the specimen below actually renders.

data-dds-accent on the element, and every component that reads the accent follows

1 2 3 4 5
<html data-dds-accent="2">                          a product's own accent

<span class="dds-avatar" data-dds-accent="3">…      one category among several
<div class="dds-chart-bar" data-dds-accent="4">

A product names the slots itself, in its own unlayered stylesheet

/* product.css — loaded last, unlayered */

[data-dds-accent="finance"] {                        a category, named for what it is
  --dds-color-accent:        var(--dds-color-accent-2);
  --dds-color-accent-subtle: var(--dds-color-accent-2-subtle);
}

[data-dds-accent="cyan"] {                           and a hue name is fine HERE
  --dds-color-accent:        var(--dds-color-accent-3);
  --dds-color-accent-subtle: var(--dds-color-accent-3-subtle);
}

Why the second one is allowed and Dessau DS’s was not. It is the same name — but in your stylesheet you own the ramp and the name, so they move in one commit, by one person, in one file. Dessau DS could not promise that: it owned the name while the ramp was yours to replace, so a slot called clay went on saying clay after you had made it grey. The rename was never “hue names are bad” — it was that a hue name belongs to whoever can keep it true. If you re-tune your own ramp later, cyan goes stale too. That is now a problem you can see and fix, which is the whole difference.

Both themes side by side

Forcing data-theme on a subtree is all it takes, because the tokens are scoped to that attribute. Useful for reviewing a change in both themes without toggling.

Light

Body text, a link, and muted detail.

Dark

Body text, a link, and muted detail.

Typography

Three roles: a display face for headings, a body face for everything read and everything in the interface, and a monospace for values a person has to transcribe. Sizes are fluid via clamp(), so there is no width at which text jumps, and all bounds are in rem so the reader’s own font-size setting still scales everything.

The three families

Space Grotesk for display, Inter for body and interface, JetBrains Mono for anything transcribed. All three OFL 1.1, all three variable. The full evaluation of the three systems considered is in docs/typography.md.

--dds-font-display · Space Grotesk

Foundations for digital products

Handgloves 0123456789 — Grüße, İstanbul, Kraków, Ærø

Geometric and systematic rather than expressive. Headline character without competing with the content. Latin extended.


--dds-font-body · Inter

Foundations for digital products

Handgloves 0123456789 — Grüße, İstanbul, Kraków, Ærø

The face that matters: every label, field, table cell and error message, for hours. Tall x-height, open apertures, unambiguous Il1 and O0, tabular figures. Latin extended, Greek, Cyrillic, Vietnamese.


--dds-font-mono · JetBrains Mono

7K4M-92QX   AB-4l7I-O9

For a value someone has to read aloud or retype. Confusable characters stay distinguishable, which is the entire job.

Dessau DS ships no font binaries

Success: What you are looking at right now

All three faces are self-hosted by this reference site, so the specimens above are the real thing.

That is the point of the arrangement: DDS itself ships no font binaries, and the reference site is a product that consumes it and wants the full identity. So it follows the exact recipe Dessau DS recommends — variable files, self-hosted, never a CDN, font-display: swap, the fallback stack kept, and the OFL notice alongside the files. See reference/assets/fonts.css, which is commented as the worked example.

What the reference site actually loads

Self-hosted font files
Family File Axis Size
Inter Inter-Variable-latin-ext.woff2 wght 100–900, opsz 117 kB
Space Grotesk SpaceGrotesk-Variable-latin-ext.woff2 wght 300–700 30 kB
JetBrains Mono JetBrainsMono-Variable-latin-ext.woff2 wght 100–800 42 kB

1200 kB became 194 kB. The Google Fonts source repository publishes TTF covering every script the family supports; these are subset to Latin and Latin Extended-A/-B and converted to WOFF2, one pyftsubset command per face, run by hand. That tooling is not a dependency of Dessau DS and is not needed to use it — the commands are in reference/assets/fonts.css, next to the one flag that is easy to get wrong.

One variable file, every weight

100 — Thin · Handgloves 0123456789

200 — Extra Light · Handgloves 0123456789

300 — Light · Handgloves 0123456789

400 — Regular · Handgloves 0123456789

500 — Medium · Handgloves 0123456789

600 — Semibold · Handgloves 0123456789

700 — Bold · Handgloves 0123456789

800 — Extra Bold · Handgloves 0123456789

900 — Black · Handgloves 0123456789

Nine weights from one file. DDS uses four of them (400, 500, 600, 700) — the rest exist because a variable axis is continuous, not because they are all worth using. A design system with nine weights has no weight hierarchy.

Why Inter for the interface — the characters that matter

Confusable characters

Il1 O0 rn m

Distinguishable at reading size, which is what a reference code or a house number depends on.

Proportional figures

1.209,40
888,10
17.004,55

Default. Fine in a sentence.

Tabular figures · .dds-numeric

1.209,40
888,10
17.004,55

Aligned. Required for any column compared vertically.

Diacritics and non-ASCII

ä ö ü ß å ø æ
ç ł ș ğ İ ı
€ £ — – „ " ‚ '

Latin Extended-A and -B: every Latin-script language in Europe.

The stacks

--dds-font-display: "Space Grotesk", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--dds-font-body:    Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--dds-font-mono:    "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;

system-ui sits before the named fallbacks deliberately: it resolves to the platform UI face, which the operating system already optimises for. The result is no layout shift, no flash of unstyled text and no third-party request — by construction rather than by tuning.

Why not ship them

  • A foundation should be byte-light; products have different delivery constraints.
  • Every self-hosted font is a licence obligation that travels with it.
  • The fallback genuinely renders well.
  • What the foundation contributes is the system — scale, measure, weights, numerals — not the face.

Self-hosting, if you want the identity

  • Take the variable woff2: one file, every weight.
  • Subset to Latin + Latin Extended-A and -B — never below that.
  • font-display: swap, never block.
  • Preload the body face only.
  • Keep the fallback stack. Ship OFL.txt alongside.
  • Never a third-party CDN — it leaks the reader’s IP address.

Full recipe: docs/typography.md.

Scale

4xl — Display --dds-font-size-4xl

3xl — Section heading --dds-font-size-3xl

2xl — Subsection --dds-font-size-2xl

xl — Card title --dds-font-size-xl

lg — Lead paragraph --dds-font-size-lg

md — Body. The default, and the minimum size for a form control. --dds-font-size-md

sm — Hints, table cells, secondary detail. --dds-font-size-sm

xs — Badges, metadata. --dds-font-size-xs

2xs — Never body copy. --dds-font-size-2xs

Weight, line-height and letter-spacing

None of the three has a semantic alias — nothing here is theme- dependent, so there is nothing for a semantic layer to re-point. Use the primitive directly, the same as radius or spacing.

--dds-font-weight-regular (400)

Set in type

--dds-font-weight-medium (500)

Set in type

--dds-font-weight-semibold (600)

Set in type

--dds-font-weight-bold (700)

Set in type

--dds-line-height-tight (1.15) · display sizes

Two lines set at the tight leading used for the largest display sizes, where lines sit close.

--dds-line-height-snug (1.3) · headings

Two lines set at the snug leading used for headings and single-line UI text.

--dds-line-height-normal (1.55) · body copy

Two lines set at the normal leading used for ordinary body copy throughout.

--dds-line-height-loose (1.7) · long-form reading

Two lines set at the loose leading used for long-form reading passages.

--dds-letter-spacing-tight (-0.02em) · large display only

Tighter

--dds-letter-spacing-normal (0)

Normal

--dds-letter-spacing-wide (0.02em) · small caps-ish UI labels

Wider

Measure

Reading comfort is governed by characters per line, not by font size, so the measure is set in ch. The default is 68 characters. Wider and the eye loses its place returning to the next line.

This paragraph sits at the default measure of 68 characters. Notice that the line length stays comfortable regardless of how wide the window is — the container caps it rather than letting text stretch to fill available space. Scannable material such as a grid of cards may deliberately be wider, because it is skimmed rather than read line by line, but running text stays here.

Numerals

Any column of numbers a reader compares vertically needs equal-width digits, or the columns do not align and comparison becomes manual work.

Default (proportional)

1,209.40
888.10
17,004.55

.dds-numeric (tabular)

1,209.40
888.10
17,004.55

.dds-code (monospace)

AB-4471-09
AB-4l7I-O9

Monospace makes confusable characters distinguishable — which is the whole reason to use it for a reference someone must read aloud or retype.

Headlines

Six levels, styled by element rather than by class. A heading is structure — it is how a screen reader builds the page outline and how a keyboard user jumps through it — so the level is chosen by what the content is, and the size follows from that.

h1 · --dds-font-size-4xl · tight leading, tight tracking

Rebuilding the harbour

h2 · --dds-font-size-3xl

What changes for residents

h3 · --dds-font-size-2xl

Access during construction

h4 · --dds-font-size-xl

Deliveries and waste collection

h5 · --dds-font-size-lg

Weekend closures

h6 · --dds-font-size-md · wide tracking

Contact the site office

Eyebrow — the kicker above a heading

.dds-eyebrow labels what kind of thing the heading below is. It is a paragraph, never a heading of its own: it belongs to the heading it introduces, and giving it its own level would put a meaningless entry into the document outline.

Planning application

Harbour redevelopment, phase two

Deliberately not uppercased

All-caps is the conventional treatment for a kicker and it is a bad one. It costs legibility, it defeats word-shape recognition — the thing that lets a reader take in a word without spelling it out — and where the source text is genuinely capitalised rather than styled, some screen readers read it letter by letter.

Weight and letter-spacing carry the distinction instead. If you do want the look, use text-transform so the underlying text stays in sentence case and only the rendering changes.

Level is structure, size is a class

Never skip a level to get a size. A section heading that needs to look small is still the next level down, with .dds-text-sm on it — which is how the headings on this page are done. Skipping from h2 to h4 leaves a gap in the outline that a screen-reader user reads as a missing section (WCAG 1.3.1).

text-wrap: balance is applied to every heading, so a two-line headline does not end in one orphaned word. It is capped by the browser at a few lines, so it stays cheap even on long ones. Body text gets text-wrap: pretty instead — same idea, but it only fixes the last line, which is what paragraphs need.

Headings carry no margin of their own. Vertical rhythm is the job of the container — see below — so a heading can sit at the very top of a card or a dialog with nothing to strip off.

Opening paragraph

Two independent, opt-in options for the paragraph that opens a piece — a size step up, a large initial letter, or neither. Applied per-paragraph rather than to .dds-prose > :first-child: which paragraph reads as the opener is an editorial call, not a position in the markup.

.dds-lead

A step up from body text — larger, slightly heavier, a touch tighter — with no relation to .dds-dropcap below. Use either, both, or neither on a given paragraph.

Work begins in March and runs until late autumn. The quayside stays open throughout, though the eastern walkway closes in two phases.

Signage goes up two weeks before each change, and deliveries continue by arrangement only for the duration of the work.

.dds-dropcap and .dds-dropcap-3

A large initial letter via ::first-letter — no markup change, so a screen reader reads the paragraph exactly as authored. .dds-dropcap sinks two lines, the default; .dds-dropcap-3 sinks three, for a wider column that can carry the larger mass. Either needs a paragraph with at least as many lines as the sink — a sink taller than its own paragraph overlaps whatever follows, the same constraint print layout has always had.

.dds-dropcap — two lines, the default

Work begins in March and runs until late autumn. The quayside stays open throughout, though the eastern walkway closes in two phases, each announced a month ahead so nothing catches a regular visitor unannounced.

Signage goes up two weeks before each change, and deliveries continue by arrangement only for the duration of the work.

.dds-dropcap-3 — three lines

Work begins in March and runs until late autumn. The quayside stays open throughout, though the eastern walkway closes in two phases, each announced a month ahead so nothing catches a regular visitor unannounced. The contractor has committed to keeping at least one route open at all times.

Signage goes up two weeks before each change, and deliveries continue by arrangement only for the duration of the work.

Vertical spacing

Space between stacked things belongs to the container, not to the things. Every element in Dessau DS starts with margin: 0, and rhythm is applied by a parent — which is what lets any component be dropped into any layout without a first or last child that needs its margin removed.

.dds-stack — one gap, applied between siblings

.dds-stack > * + *: the space goes between children only, never above the first or below the last. Nothing to collapse, nothing to reset at the edges.

.dds-stack-xs

.dds-stack-md

.dds-stack-xl

.dds-prose — rhythm for long-form text

The same idea with one addition that matters: a heading gets more space above it than below. That is what makes it read as belonging to the text it introduces rather than floating between two paragraphs.

Work begins in March and runs until late autumn. The quayside stays open throughout, though the eastern walkway closes in two phases.

Access during construction

The main entrance moves to Kavalierstraße for the duration. Signage goes up two weeks before the change.

  • Pedestrian access at all times
  • Cycle parking relocated to the north side
  • Deliveries by arrangement only

Weekend closures

Four weekends in total, published a month ahead. Nothing closes without notice.

--dds-space-md between any two blocks
--dds-space-xl above a heading
--dds-space-sm below a heading, tying it to what follows
--dds-space-2xs between list items

The escape hatch, and when to use it

.dds-mbs-* and .dds-mbe-* set a single margin on one element, for the case a container cannot express: one item that needs to sit further from its neighbour than the rest.

Reaching for them repeatedly inside one block is the signal that the block wanted .dds-stack instead. Both are logical properties, so they follow writing direction rather than being fixed to top and bottom.

Spacing

A 4px base with a deliberately thin ramp. There are no in-between values: a layout that needs 14px is a layout that has not decided yet. Reaching for a step that does not exist silently invalidates the whole declaration, which is what scripts/check-css.mjs guards against.

The top of the ramp is reachable now too

.dds-stack-3xl, .dds-mbs-3xl and .dds-mbe-3xl reach --dds-space-3xl (72px, "between major regions") the same way .dds-stack-ml reaches the ramp's one midpoint — a step defined in the primitive layer had no utility that could reach it, which forced an inline style anywhere a gap that size was needed (#136).

--dds-space-4xl (96px, exactly 2x --dds-space-2xl) goes one step further, with matching .dds-stack-4xl, .dds-mbs-4xl and .dds-mbe-4xl utilities (#137) — hero-scale headroom above any named region, added ahead of a specific page adopting it. Shipped first at 100px, then corrected to 96px for fitting the ramp's multiplicative relationships better than a value chosen for being close to a round number.

Breakpoints

Four named widths, for the page shell. Components do not use them — a component responds to the space it is given, not to the size of the window, so it uses a container query instead. These exist so the shell has a documented set to choose from and so a conversation has vocabulary for it.

Read from the stylesheet at runtime and updated as you resize, so this cannot show a value the CSS no longer has.

A custom property cannot be used in a query

@media (min-width: var(--dds-breakpoint-tablet)) does not work, in any browser. A query condition is evaluated before custom properties are resolved, so the value is never substituted and the query simply never matches — silently, with no error.

So every query writes its own literal, and these four names are documentation rather than something the browser enforces. scripts/check-reference.mjs compares the documented set against every width actually used in the CSS, which is what keeps the two from drifting apart.

A track's minimum is its content, and that is how one wide thing widens a page

A grid track sized auto, and any grid or flex item, cannot be narrower than its own min-content. So a single descendant that refuses to become narrow — a table without its scroll region, a max-content column, a long unbroken identifier — makes its track wider than the viewport, and every sibling in that track is stretched to match.

That is what turns one local overflow into a page-wide symptom. The grids inside compute their column count against a width the screen does not have and put the later columns off-screen, so each of them looks like a wrapping bug while wrapping exactly as written. Anything laying children out in a track therefore constrains both: grid-template-columns: minmax(0, 1fr) on the track and min-inline-size: 0 on the items. Constraining only the track moves the inflation one level down.

Component thresholds are not breakpoints

A component's own switching point comes from its content: the width at which two columns of its text stop being readable, or its labels stop fitting beside its controls. That width has no relationship to any device, so it is not one of the four and should not be rounded to one.

Every width used in a container or media query, generated from the stylesheets
Width Query Applies to Why this width
26rem
416px
@container dds-address patterns.css Postcode and town share a row above 26rem.
28rem
448px
@container dds-pagination components-navigation.css Narrow: the numbers become a strip that scrolls, and Previous and Next stay with them.
30rem
480px · named
@media components.css A dialog anchored to the bottom edge on small screens: easier to reach one-handed.
34rem
544px
@container dds-steps components-navigation.css Steps turn from a stacked list into a row at 34rem.
34rem
544px
@container dds-topbar components-navigation.css Below 34rem the actions drop onto their own row.
40rem
640px
@container dds-textmedia components-content.css Text beside media above 40rem, stacked below.
40rem
640px
@container dds-sitefooter components-navigation.css The footer's link groups sit side by side from 40rem.
48rem
768px · named
@container dds-siteheader components-navigation.css The header's navigation is inline from 48rem and behind a disclosure below it.
52rem
832px
@container dds-filtering patterns-flows.css Filters move from above the results to a column beside them at 52rem.
64rem
1024px · named
@container dds-contentnav components-navigation.css The panel becomes a permanent column at 64rem.

Generated from the stylesheets by scripts/sync-breakpoints.mjs, including the reason in the comment above each query. Hand-writing this table produced three wrong rows on the first attempt, and a wrong table reads exactly like a right one. The same data, with full detail, is in dds/foundations.json under breakpoints.inUse.

Icons

A fixed, small set from Ionicons, inlined as an SVG sprite. A foundation rather than a component: the set is a shared vocabulary, in the same way the colour ramp is, and adding to it is a decision about the system rather than about one screen.

If the role you need is not here, add it — do not borrow a near one

This is the rule that gets broken most quietly. A <use> pointing at the wrong role resolves, renders, passes every check in scripts/, and ships a picture that means something else. Only a person reading the markup ever notices.

Four had accumulated before this note existed: a sun on the “Show password” button, a document on both the upload zone and the download link, and the navigation hamburger on a toolbar’s overflow menu. Each was one line from correct. What was missing was not care — it was an icon, and permission to add one.

So: adding a line to ICON_MAP in scripts/build-icons.mjs and re-running the build is the cheap, expected, correct move. Ionicons has around 1,300 icons; Dessau DS ships the handful it names. The constraint is that every icon earns a role name and a caller — not that the set stays at its current size. A Unicode glyph is not the escape hatch either: a typed check mark is announced as “check mark” in the middle of a sentence and renders in whatever font happens to have it, which is why scripts/check-icons.mjs rejects twenty-five characters by name.

One icon, one meaning — and directions come as a set

The corollary of role naming: a role means one thing everywhere. menu is the hamburger and means site navigation, so an overflow menu is more, not a second use of the hamburger. document is a file, so getting one is download and sending one is upload. A picture that means two unrelated things teaches a reader that it means neither.

Directions are the one exception to “every icon has a caller”. All four chevrons and all four arrows are in the set, including chevron-up, arrow-up and arrow-down, which nothing here points at today — “up” is meaningful because “down” is, not because a page happens to use it this week. Those three are declared in ICON_MAP with the reason, marked data-dds-vocabulary in the sprite, and listed by check-icons.mjs on every run, so the exemption is argued rather than silent.

A disclosure still rotates chevron-down through 180° rather than swapping symbols: the rotation animates the change and keeps both states unmistakably the same control. That is a good technique — it just was not a reason for the vocabulary to have a hole in it.

The name is the role, not the picture

#dds-icon-error, not #dds-icon-alert-circle. Markup refers to what the icon means, so swapping which Ionicon backs a role — or replacing Ionicons entirely — changes one line in scripts/build-icons.mjs and no markup at all. A set named after shapes hard-codes today's drawing into every page.

Where fill and stroke live, and why not in CSS

.dds-icon sets size and nothing else — no fill, no stroke, no stroke-width. That looks like an omission and is the opposite.

Ionicons carries those as inline attributes, tuned per path: an outline icon has fill:none with a stroke, and the solid details inside it have a fill and no stroke. A CSS declaration beats a presentation attribute, so fill: currentColor in the stylesheet filled every outline icon in solid, and the paths that rely on fill:none disappeared entirely. Both at once, which is why it read as "the icons are broken" rather than as a specificity problem.

Theme awareness comes from the build instead: scripts/build-icons.mjs replaces Ionicons' hard-coded #000 with currentColor, so an icon takes the colour of its container — including inside a button whose text colour changes on hover.

Sized in em, and always accompanied

The size is in em, so an icon matches whatever text it sits beside at any font size, with no per-context override.

Every icon in Dessau DS is aria-hidden="true" and focusable="false". An icon never carries meaning alone: a control's name is real text, visible or in a .dds-sr-only span. An icon-only button with no accessible name is announced as "button", and a status shown only as a shape and a colour is unavailable to a good share of readers. focusable="false" is there for older Internet-Explorer-era behaviour where an inline SVG became a tab stop; it costs nothing and removes a whole class of stray focus.

The sprite is inlined into each page by scripts/sync-icons.mjs — not fetched. A <use href="sprite.svg#id"> pointing at an external file does not work from file:// and needs a request before anything renders. scripts/check-icons.mjs verifies every <use> resolves to a symbol that is present, because one that does not renders as empty space, silently.

Text selection

Select some of the text below. The selection colour is themed, which is not cosmetic: the browser default is a fixed blue that has no relationship to a dark surface, and dark text on it can fall below any usable contrast.

Drag across this paragraph. The highlight uses --dds-color-selection-bg and the text switches to --dds-color-selection-text, so the pair is contrast-checked like every other pair in the system rather than being left to whatever the platform picks.

Try it in both themes. The two values change together — a selection background that follows the theme while the text colour does not is the exact shape of the bug this avoids.

--dds-color-selection-bg

--dds-color-selection-text

The pair, as it renders

Selected text at 3 rem of prose

Both halves, or neither

::selection is the one place where setting a background without also setting a colour is actively harmful. The browser will not adjust the text for you, so a themed highlight under unthemed text produces a selection that is unreadable — and only while it is selected, which is the hardest state to catch in review.

accent-color is bound to the action colour for the same reason: checkboxes, radios and range thumbs are painted by the platform, and left alone they stay the operating system's blue on every surface Dessau DS draws.

Container and form widths

Two different questions, answered by two different scales. How wide may a page region be? — containers, in rem. How wide may a line of text be before it becomes hard to read? — measures, in ch.

Containers — a page region's maximum width

.dds-container centres itself, caps its width and adds a gutter. Change the cap by setting --dds-container-width on it, rather than by writing a new max-width somewhere.

Narrow: these five run from 384px to 1200px, so on a phone none of them fits and every bar would otherwise be drawn at whatever space was left — five identical bars, in the one specimen whose job is to show a ramp. Where the widest does not fit, the ramp is drawn to scale instead and says so underneath. The ratios stay true; only the size is lost, and the number beside each bar is still the real one.

Measures — a line of text's maximum width

In ch, so the limit is a character count rather than a distance. That is the point: the readable limit is about how far the eye travels between lines, which depends on the size of the type, not on the size of the screen. A ch value scales with the font automatically; a rem value would have to be re-tuned for every type size.

--dds-measure-narrow · 45ch — a caption, a card body, an aside

Work on the eastern quay begins in March and runs until late autumn, with the walkway closing in two separate phases.

--dds-measure-default · 68ch — body text, the default for prose

Work on the eastern quay begins in March and runs until late autumn, with the walkway closing in two separate phases. The main entrance moves to Kavalierstraße for the duration, and signage goes up two weeks before the change so nobody arrives to find a locked gate.

--dds-measure-wide · 88ch — dense tabular or reference material

Work on the eastern quay begins in March and runs until late autumn, with the walkway closing in two separate phases. The main entrance moves to Kavalierstraße for the duration, and signage goes up two weeks before the change so nobody arrives to find a locked gate. Deliveries continue by arrangement throughout.

Form widths

A form is read one field at a time, so it stays narrow no matter how much room there is. A field stretched across a wide screen is harder to scan, not easier — the eye has to travel from the label on the left to the input's content, and back again for the next row.

Form width classes
Class Width For
.dds-form 32rem The default. One column of fields, read top to bottom.
.dds-form-wide 45rem Only when fields genuinely pair up — postcode and town, from and to.
.dds-form-row auto-fit, min 12rem Two fields side by side, stacking themselves when 12rem each is no longer available.

Why 12rem is the minimum in a row

minmax(min(12rem, 100%), 1fr) — the inner min() is what stops the row overflowing below 12rem of available space. Without it, the track keeps its 12rem floor, the grid grows past its parent, and the page scrolls sideways, which fails WCAG 1.4.10 Reflow at 320px.

Radius and elevation

Smaller radii for things that sit inside other things, larger for containers. Elevation is a two-part shadow — a tight contact shadow plus a soft ambient one — because a single blur reads as a glow rather than as height.

--dds-radius-sm

--dds-radius-md

--dds-radius-lg

--dds-radius-pill

--dds-radius-none

--dds-radius-circle

--dds-radius-none is what a fully square system points every other radius step at — one primitive, not four independent zeroes. --dds-radius-circle is geometry rather than taste: avatars, the progress ring and the step marker are circles regardless of the roundness decision.

--dds-border-thin (1px)

--dds-border-thick (2px)

--dds-border-thick exists so emphasis never depends on colour alone — a selected card gains weight, not just hue.

--dds-elevation-sm

--dds-elevation-md

--dds-elevation-lg

Motion

Short durations only. Motion reports a state change or shows where something came from; it is never the point of interest. Everything is neutralised globally under prefers-reduced-motion: reduce — a global switch, not a per-component opt-out, because a component that forgets to honour it can genuinely make someone ill.

Duration rows share --dds-ease-standard so only the timing changes; easing rows share --dds-duration-slow (long enough to see the curve) so only the shape changes. Static until played; instant instead on a system with reduced motion turned on, same as every real component on this page — and it returns to the start a moment after arriving, so it is always ready to play again.

Motion durations and easings, with a live demo of each
TokenValueDemoUse
--dds-duration-instant
Hover, focus, colour swaps
--dds-duration-fast
Small reveals, toggles
--dds-duration-base
Dialogs, panels
--dds-duration-slow
Full-surface transitions only
--dds-ease-standard
Arrivals — decelerating
--dds-ease-exit
Departures — accelerating
--dds-ease-emphasis
A slight overshoot

Z-index

A closed scale. Ad-hoc values are how stacking bugs are born, so anything that stacks picks a name from this list or does not stack. Native <dialog> and popover render in the top layer and sit outside the scale entirely — which is one of the better reasons to use them.

Z-index scale
TokenValueLayer
--dds-z-base0Normal flow
--dds-z-raised10Sticky header, sticky table head
--dds-z-dropdown20Listbox, menu, popover
--dds-z-scrim50Overlay behind a panel
--dds-z-panel60Off-canvas, drawer
--dds-z-dialog70Modal content
--dds-z-toast80Status messages
--dds-z-tooltip90Always outermost