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
dds.resetthe smallest sane floordds.foundationprimitive and semantic valuesdds.baseelement defaults, focus, motion, forced coloursdds.typographytype utilities and the reading measuredds.layoutcontainer, stack, cluster, grid, sidebardds.componentsbuttons, fields, cards, dialogs …dds.patternscompositions that solve a taskdds.utilitiessingle-purpose helpers- 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.
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
| 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, neverblock.- Preload the body face only.
- Keep the fallback stack. Ship
OFL.txtalongside. - 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.
| 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.
Links
Always underlined in body text. Colour alone is not a sufficient distinction from surrounding text (WCAG 1.4.1), and an underline is the one convention every reader already knows without being taught it.
Work on the eastern quay begins in March. The full programme lists every phase, and the colour section of this page is a link you have already followed — visited links take their own colour, which is a genuine navigation aid and not decoration. A link inside a sentence sits on the same baseline as the text around it, so the underline has to clear the descenders in words like judging typography rather than cutting through them.
Default · --dds-color-text-link
Hover · thicker underline, not just a colour change
Visited · --dds-color-text-link-visited
External · the icon is part of the link
Where the underline is dropped, and why that is allowed
Navigation links, a brand link, a link styled as a button, a card whose whole surface is clickable — none of these are underlined, because none of them are ambiguous. The rule protects a link inside a run of text, where without an underline it is distinguishable only by hue.
Hover thickens the underline as well as changing the colour, so the feedback survives for someone who cannot see the hue change. The same reasoning applies throughout Dessau DS: no state is ever carried by colour on its own.
A link's text has to make sense read on its own. “Read the full programme”, not “click here” — a screen-reader user can list every link on the page, and in that list “here” appears with nothing to attach it to.
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.
| 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.
| Token | Value | Demo | Use |
|---|---|---|---|
--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.
| Token | Value | Layer |
|---|---|---|
--dds-z-base | 0 | Normal flow |
--dds-z-raised | 10 | Sticky header, sticky table head |
--dds-z-dropdown | 20 | Listbox, menu, popover |
--dds-z-scrim | 50 | Overlay behind a panel |
--dds-z-panel | 60 | Off-canvas, drawer |
--dds-z-dialog | 70 | Modal content |
--dds-z-toast | 80 | Status messages |
--dds-z-tooltip | 90 | Always outermost |