Components
One thing, one purpose
Every variant and state rendered together, so a gap is visible rather
than theoretical. Anything shown here is documented in
agent/components.md; anything documented
there appears here.
Button
Emphasis is fill and border weight, not hue — the ordering still reads
in greyscale. Use <button> for an
action and <a> for navigation: the
choice decides whether Space activates it, whether it opens in a new
tab, and how it is announced.
Emphasis
Sizes — all above the 24×24px WCAG 2.2 minimum
Disabled — both spellings
aria-disabled="true" keeps the control
reachable, which is better when the user needs to find out
why it is unavailable. A disabled
button cannot be focused, so it cannot carry an explanation.
With icons, and icon-only
Busy
The spinner is decorative. Progress is announced through a live region — an animation communicates nothing to a screen reader.
Do
- Name the action with a verb: “Save changes”, “Delete project”.
- One primary button per view.
- Give an icon-only button a visually hidden label.
Don’t
- Use “OK”, “Yes” or “Submit” where a verb would be clearer.
- Use a button for navigation, or a link for an action.
- Rely on the danger colour to convey that something is destructive.
Field
The form primitive: label, control, hint, error. Every rule below exists because breaking it makes the form unusable for someone.
States
We use this to send confirmations.
We use this to send confirmations.
Error: Enter an email address, for example name@gmail.com
Assigned automatically. Contact support to change it.
Monospace, so confusable characters stay distinguishable.
Type 7K4M-92QX and leave the field. The tick appears only once the value is valid and you have committed to it.
Why positive confirmation is opt-in
Confirming every field the moment it is correct is noise — most fields are correct, and saying so about all of them means saying nothing. The tick earns its place on the few where the user genuinely cannot tell whether they got it right: a transcribed code, an account number, a password meeting a rule they cannot see.
:user-valid, not
:valid.
:valid would tick an empty optional field
from page load — the mirror image of why the error state does not use
:invalid.
The tick is aria-hidden. That a value is
acceptable is already communicated by the absence of an error, which a
screen reader gets for free; a second “this one is fine” per field is
noise in the one place noise costs most.
The invalid field keeps its hint. The user needs the format rule and the failure — replacing one with the other removes the information they need to fix it.
Other control types
A native select: correct on touch, with a keyboard, and in every language.
The wrapper carries the border, so the icon sits inside the focus ring.
Currency in euros.
Password field
A text field with a reveal toggle. Masked by default, and never masked in a way that stops a password manager working.
Password — the reveal toggle is not optional
Written as a bare <input type="password">. The wrapper and the button were added by DDS.
The field is marked lang="de", which a screen reader needs anyway to pronounce the label. The button’s name and the announcement follow it — nothing here asks for German twice.
data-dds-password="off" — no wrapper, no toggle, no way to reveal it. Rare, and it needs a reason.
A disabled field disables its toggle too — there is nothing to reveal.
Why this one enhancement is not opt-in
Every other behaviour in DDS waits for a
data-dds-* attribute. This one does not,
because a missing reveal toggle is an accessibility defect rather than a
missing feature: WCAG 2.2 3.3.8 Accessible Authentication forbids a
cognitive function test without an alternative, and typing a long
password blind is exactly that test. Opt-in would make the compliant
version the one somebody remembered — and a password field without a
toggle looks completely normal, so nothing would ever report it.
The button keeps one accessible name in both states, with the
state in aria-pressed. A button that renames
itself when pressed is announced as a different control each time. The
icon names the action; the field itself — dots or characters — is the
visible state.
Only type changes.
autocomplete is never rewritten and paste is
never blocked: breaking the password manager would defeat the criterion
the toggle exists to satisfy.
Do
- Give every control a visible
<label for>. - Write “(required)” in words.
- Reference hint and error together from one
aria-describedby. - Set the right
autocompletetoken (WCAG 1.3.5).
Don’t
- Use a placeholder as the label — it vanishes on input.
- Mark required fields with an asterisk or colour alone.
- Style validity from CSS
:invalid— it matches before the user has typed. - Remove the hint when an error appears.
Checkbox and radio
Native inputs, laid out — not replaced.
accent-color tints them, which is all
that is needed. A replacement built from a hidden input and a styled
span has to rebuild keyboard behaviour, indeterminate state,
forced-colours rendering and grouping semantics, and usually gets at
least one wrong.
Choice cards — selection changes fill AND border weight
Field group
A <fieldset> with a
<legend>, for fields that only make
sense together. The legend is read before each field inside it, which
is what makes “Von” and “Bis” mean something on their own.
Related fields under one legend
A group heading in a <div> looks
identical and is not read with the fields, so a screen-reader user
hears “Von” with nothing to relate it to. The same element groups a
set of checkboxes or radios, where the legend is the question the
options answer.
Narrow:
.dds-fieldgroup-options-inline puts the
fields side by side and wraps when they no longer fit. It was a grid
with max-content columns, which cannot
wrap by definition and made the row wider than the screen (#77).
Switch
For a setting that takes effect the moment it is changed. Anything that needs a Save button is a checkbox.
Switch — for a setting that takes effect immediately
A switch means “this is now on”. A checkbox means “this will be saved”. Users read the difference, so it should be true.
Icon variant — the thumb carries the state
Both icons are in the markup; CSS shows the one that matches the current state and positions it as the thumb, so there is no JavaScript icon swap and no flash of the wrong icon on load. The label stays constant — the icon carries the state, same as the track fill does on the plain switch. This is a generic switch capability, demonstrated here with a sun/moon pair; it is not wired to the page theme and does not replace the theme toggle below, which is a deliberately different control (an action button styled as a text link, for a header or footer utility row).
Number stepper
Three places where the native element genuinely is not enough on its own — and three where it is still the control underneath.
Number stepper
Between 1 and 99.
The native spinner buttons are about 10px tall, appear on hover only
and are absent on touch entirely — unusable for a value someone
actually adjusts. The input stays authoritative for
min, max
and step and remains typeable; the
buttons disable at the limits so the control shows its own
boundaries.
Segmented control
Two to five mutually exclusive options, all visible at once. Built from radios, so the keyboard behaviour is the platform's.
Segmented control
Built on radio inputs, so it is one tab stop with arrow keys between options — the native radio-group behaviour is exactly right here. Two to four short labels; more than that and it should be a select.
File upload
A real file input, styled. What it accepts and how large a file may be is said before the dialog opens, not after.
File upload
PDF, JPG oder PNG, je bis 10 MB. Dateien können auch hierher gezogen werden.
accept must match the formats the text
promises, or the picker shows every file on the device and the
restriction is discovered only after choosing wrongly. Drag-and-drop
is additive — dragging is impossible for many people
(WCAG 2.2 2.5.7), so the button is always the primary route.
lang="de" sits on the component, not on
the two German sentences inside it. It used to sit only on those,
because the file list the script builds was English whatever the page
said — so tagging the whole thing would have been a lie. Choose files
here: the list, the sizes, the rejection wording and the announcement
are all German now, from a table beside the behaviour (#20).
Range
A slider for a value whose exactness does not matter, and a live count for a limited field.
Range — with its value shown, because a slider alone says nothing
Only appropriate when the exact value does not matter. If it does, use a number field — a slider cannot be operated precisely with a trackpad, and is very hard to operate with a tremor.
Character count
How much room is left in a field that has a limit. The limit itself is
the input's maxlength, so the browser
enforces it whether or not the script runs.
Character count — visible on every keystroke, announced when it matters
The count updates visibly on every keystroke but is announced only when it starts to matter, debounced. A live region reading a number after every character is unusable.
Date field
The native date control, and a range built from two of them. The expected format is stated in a hint, because the placeholder is the browser's and follows the locale.
Date field — the native picker
Format: TT.MM.JJJJ
Frühestens heute. Format: TT.MM.JJJJ
The autocomplete rule is easy to
get backwards. A date of birth takes
bday — WCAG 1.3.5 expects it, and it is
the field where autofill helps most. Only a future date takes
off, because there is nothing to fill.
Showing just the second case teaches people to copy off onto a birthdate.
Date field and date range
Format TT.MM.JJJJ
Beide Felder ausfüllen. Das Enddatum darf nicht vor dem Startdatum liegen.
The autocomplete rule, which is easy to get backwards
A birthdate takes autocomplete="bday".
WCAG 2.2 1.3.5 expects it, and it is the field where autofill helps
most — someone who struggles to type a date gets it filled in.
A future date takes autocomplete="off",
because there is nothing stored to fill it with. Copying that onto a
birthdate field is the common mistake, and it silently removes the
help from the one place it mattered — which is why both cases are
shown here rather than just one.
Why the native picker stays
The platform control knows the locale's date order, the first day of the week, the calendar system and the device's own conventions. On a phone it is a purpose-built control that a scripted one cannot match.
A JavaScript date picker has to rebuild all of that, usually gets the keyboard wrong, and is the single most common source of "I cannot enter my date of birth". The visible format still differs by locale and platform, so the expected format always goes in a hint — it cannot be inferred from the control.
color-scheme is bound to the theme, so
the picker's own chrome follows light and dark with no styling at
all. Switch the theme and open the picker to see it.
Theme toggle
Deliberately styled as a text link rather than a button: it sits in a header or footer utility row, where a filled control would out-shout the navigation beside it. The theme is a preference, not an action the page wants you to take.
With label — preferred
Icon only — compact
German wording — from lang, not an attribute
Press any of them — including the one in the page header. All toggles in a document share one mechanism and stay in sync, because they all reflect one piece of state. Both icons are in the markup and CSS chooses which shows, so there is no flash of the wrong icon before the script runs.
Why there is no aria-pressed
The visible label says where pressing takes you — “Dark” while light is active. The accessible name must therefore say the same thing (WCAG 2.5.3 Label in Name), which makes this an action button, not a toggle button.
There are two valid patterns, and they must not be mixed:
- Action button — the name changes, no
aria-pressed. This one. - Toggle button — the name stays constant,
aria-pressedcarries the state.
Combining them announces “switch to dark theme, not pressed”, which double-encodes the state and leaves the listener unsure whether the control describes the current mode or the next one.
Pressing announces the result — “Light — light theme on” — not the next action. The label has just changed to describe what pressing again would do, which is not what the user needs to hear.
Do
- Use a real
<button>, however link-like it looks. - Prefer the labelled variant — sun and moon alone are ambiguous about which is the current state.
- Give the icon-only variant an accessible name; the script sets one.
- Keep the toggle reachable on every page — it is the only way out of a default someone cannot read comfortably.
- Persist the choice, and let it outrank the system preference.
Don’t
- Use an
<a>— it does not navigate, is announced as a link, and does nothing on Space. - Set both a changing name and
aria-pressed. - Carry the meaning in icon colour alone — the shape and the label do it.
- Swap the icon in JavaScript; CSS already knows the theme.
- Hide it away in a settings page.
Badge
Badge — status variants always pair colour with a word and an icon
Badge — a category is not a status, and says so with a tag
A category badge stays neutral. Giving it one of the status colours would make “Finance” read as a warning, and it would spend a colour that has to keep meaning exactly one thing. The tag icon is what separates the two at a glance, which is why it is a role in the set rather than a reused document or chip glyph.
Card
A container for one thing that can be summarised and acted on. It is not a layout: a card that holds unrelated content is a box.
Card
Default
A bounded content group with a subtle edge.
Raised
Floats above the default plane.
Sunken
Recedes — for wells and inset areas.
Interactive
The whole card is the hit area, but only the heading text is the accessible name.
Notice
Which ARIA role belongs on it depends entirely on when it
appears. Present at load: no role. Appears after an action:
role="status". Blocks progress:
role="alert". Putting
role="alert" on a notice that is already
on the page means it is announced on load, out of context.
Information: Scheduled maintenance
Exports will be unavailable on Sunday between 02:00 and 04:00.
Success: Changes published
Your edits are live. Anyone with the link can see them.
Warning: Two collaborators have no access
They will not see this project until you grant them permission.
Error: Could not save
The connection dropped. Your changes are still in the form — try saving again.
Dialog
Native <dialog> opened with
showModal(). Focus is moved in and
trapped, the page behind is made inert, Escape closes, and it renders
in the top layer so no overflow ancestor
can clip it — all from the platform, none of it reimplemented.
One variant depends on the device rather than on space:
.dds-dialog-sheet anchors to the bottom
edge below 30rem of viewport, because “reachable by a thumb” is
a property of the screen and not of the box the dialog was given. It is
therefore the one case a width switcher cannot show: the switcher
narrows a stage, and a media query reads the window. Resize the browser
to see it, or open the bottom sheet on a phone.
Do
- Name the dialog with
aria-labelledby. - State what will happen, and to how much.
- Label the confirming button with the verb, not “OK”.
Don’t
- Use
<dialog open>— it renders non-modally and Escape does nothing. - Ask “Are you sure?” — say what is at stake instead.
- Make the destructive button the default focus target.
Table
A real table with real <th scope>.
The wrapper is not optional: an overflowing table needs a scroll region
that is focusable and named, or the content past the edge is
unreachable without a mouse — and the table sets the width of the page
for everything else on it.
Two mechanisms keep that true, because a rule in a comment does not.
scripts/check-reference.mjs fails on a
table here that is not directly inside
.dds-table-wrap, and the
table enhancement builds the wrapper at
runtime for one that has none. Twelve of the fourteen tables in this
reference had no wrapper before that existed — eight of them inside a
dds-scroll class that no stylesheet
declares, which reviews as careful markup and does nothing at all.
The enhancement also draws a shadow at whichever edge has more content beyond it. On a phone there is no scrollbar until a finger moves, so a table that scrolls and a table that is cut off look identical, and “cut off” is what a reader assumes. Without JavaScript there is no shadow: the table still scrolls, the region is still focusable and still named. The cue is the enhancement; the access is not.
| Project | Owner | Status | Documents | Budget |
|---|---|---|---|---|
| Harbour redevelopment | Ilva Bergström | Approved | 47 | 24,500.00 |
| Kalvebod cycle bridge | Tomasz Wierzbicki | In progress | 8 | 112,300.00 |
| Grassmarket paving survey | Nadja Öztürk | Needs review | 1,204 | 3,940.50 |
Numeric columns are right-aligned and tabular, so digits line up for
comparison down the column. The first cell of each row is a
<th scope="row">, which is what lets
a screen reader say which project a value belongs to.
Tabs
Tabs — one tab stop, arrow keys to move
Redevelopment of the eastern quay, including public access and drainage.
Four people have access. Ilva Bergström is the owner.
Last edited eleven days ago. Approved on 4 March.
Tabs are right only when the panels are alternative views of one thing. If the content should be readable in sequence, linkable, or findable by in-page search, use headings and sections.
Disclosure
One `<details>`, on its own. Open and close, keyboard operation and the expanded state come from the platform.
Disclosure — one, on its own, no JavaScript at all
What counts as a committed contract?
One that has been signed. A tender that has been issued but not awarded is not committed.
Open and close, keyboard operation and the expanded state exposed to
assistive technology all come from
<details>. Several of these
stacked, sharing one border box, is the accordion below.
Accordion
A stack of disclosures sharing one border box. Whether only one may be open at a time is decided by one attribute, and neither answer needs a line of JavaScript.
Accordion — one open at a time, from name
How is a budget figure calculated?
Approved line items plus committed contracts, excluding provisional estimates.
Who can change a project’s status?
The owner and anyone with the reviewer role.
Can a deleted project be recovered?
No. Deletion removes the project and its documents for everyone.
The exclusive behaviour comes from the
name attribute on
<details>, shared by every item in
the set. No script is involved, and closing the open item when
another is opened is the browser's job rather than a listener's.
Leave name off when
the reader may want two answers side by side — a set of specifications
being compared, or steps referred back to while working. Use it when
the items are alternatives and an open one would only be in the way.
One open at a time is the tidier default and the more annoying one:
it takes away a choice the reader might have wanted.
Search field
An input and its submit as one unit, in a real form, so Enter submits and the browser offers previous searches.
Search field — one unit, real submit
role="search" makes it findable as a
landmark, and type="search" gets the
platform's clear control, query history and the “Search” return key on
iOS. The button has a name — a magnifier alone is
announced as “button”.
Keyboard keys
`<kbd>` for a key a reader is being asked to press — which is what makes it findable and translatable, unlike a styled span.
Keyboard keys
Press Esc to close, or Ctrl + K to search. On a Mac, ⌘ + K.
Divider
A rule between groups of content. Decorative by default, and a real separator only where the grouping is meaningful.
Divider
Content above the rule.
Content below it.
A real <hr>, so it is a thematic
break in the document rather than a styled empty div. Use it between
groups that layout does not already separate — a rule that merely
repeats existing spacing is noise.
Tooltip
Tooltip — supplementary only
Aufbewahrungsdauer Dokumente bleiben zehn Jahre gespeichert und werden danach automatisch gelöscht.
Only the icon is the button. The term is a term — wrapping it in a button draws a word the reader is meant to read as something they are meant to press, and every button convention in the system then says pressing it will do something. The trigger is its own small control, with its own name: “Erklärung zur Aufbewahrungsdauer”, not “Info”, because a name is read out of context often enough that it has to say which term it explains.
aria-describedby as well as
popovertarget. Opening a popover does
not move focus and announces nothing, so without it a screen-reader
user can press the trigger and hear silence. Referenced description
text is in the accessibility tree whether or not the popover is open,
which is exactly right for something supplementary.
Never the only place information lives. A tooltip is
unavailable on touch, easy to miss, and it disappears — anything
essential belongs in a hint below the field. Built on
popover, which supplies the dismiss
behaviour WCAG 2.2 1.4.13 requires and renders in the top layer.
Bottom sheet
The same dialog, anchored to the bottom edge on a phone — the one place a viewport media query is right, because reachability by thumb is a property of the device.
Bottom sheet — the same dialog, anchored low
Narrow the window below 30rem to see it. Above that it is an ordinary centred dialog; below, it anchors to the bottom edge with square lower corners.
Toast
Toast
A toast confirms something that already happened. It must never be the only place important information lives, and never contain the only route to an action — it disappears on a timer.
Copy
Puts a short value on the clipboard from an ordinary button. No
dedicated class of its own — data-dds-copy
attaches to whatever button markup is already there.
Copy — a reference value beside its button
DSS-2026-04-1180
Both outcomes are announced and toasted — a copy that silently succeeds leaves the user unsure whether to press it again. Where the async Clipboard API is unavailable, the button hides itself and the value stays selectable as ordinary text, rather than offering a control that does nothing.
Progress
A wait whose end is known. The one of the three that states a proportion, so it may only be used where the proportion is real.
Progress — a wait whose end is known
A native <progress>, so the value is
announced and the text inside it is what a browser without support
shows. Only where the proportion is real — a bar that reaches 90% and
waits is worse than no bar.
Spinner
A wait whose end is not known. Decorative by definition: the waiting is said in words beside it, because a rotating shape announces nothing.
Spinner — always paired with a live region
Loading projects…
The spinner is aria-hidden and the text
carries role="status". Under reduced
motion the rotation becomes an opacity pulse — the global duration
collapse would otherwise leave a static broken ring.
Skeleton
The shape of what is coming, while it comes. For content whose layout is known in advance — a list of rows, a card — not for a wait of unknown shape.
Skeleton — the region says it is busy, the shapes say nothing
aria-busy on the region and
aria-hidden on the shapes: a screen reader
is told the region is loading rather than made to read three empty
boxes. A skeleton that does not match what replaces it is a worse first
impression than a blank space.