Patterns
Components plus behaviour, solving a task
A pattern owns what a single element cannot: focus order, live announcements, request lifecycle, error recovery, and the fallback when the clever path fails. That is where most of the real accessibility work lives.
Address search
The reference pattern, because it exercises everything at once: components, asynchronous behaviour, accessibility, progressive enhancement and an abstraction over an external service.
Live — searching example data
Type at least 3 characters. You can also fill in the fields below yourself.
Never filled in by the search — no address service knows it.
Try “Copenhagen”, “1000” or “Königsallee”. Search by street, postcode or city — people search by whichever part they remember.
Above 26rem of its own width the postcode and the town sit side by side; below it they stack. The threshold comes from the fields, not from a device: it is the width at which a five-digit postcode and a town name stop fitting on one line together. The component is its own container — it styles only descendants, so it needs no frame element.
The states that actually break in production
A pattern is not done because the happy path renders. Switch the provider’s behaviour and check each one — including with a screen reader, since three of the four are announced rather than seen.
Provider: normal.
Do
- Keep the structured fields present and editable, always.
- Put street and number in one field — formats differ by country.
- Clear the secondary line when a new address is selected.
- Set
autocompleteon every field (WCAG 1.3.5). - Announce that the fields were filled in.
Don’t
- Lock, hide or disable a field after selection — providers are wrong sometimes.
- Require a match. Real addresses are missing from every database.
- Split street and number into two required fields.
- Treat “service failed” and “no such address” as the same message.
- Query on every keystroke without aborting the previous request.
Information: Swapping the provider
The pattern knows nothing about where addresses come from. It talks
to one object with one search() method
— see dds/js/providers/address-provider.md.
Replace the provider and nothing else changes.
Autocomplete
The base that address search composes. Focus stays in the input — the
user is still typing — and the active option is pointed at with
aria-activedescendant. That is the detail
most implementations get wrong.
Arrow keys to review, Enter to choose, Escape to close.
Use a native <select> for a short
list. An autocomplete costs the user a decision about whether to type
or to browse, and that is only worth paying above roughly fifteen
options.
Form validation
Constraints stay in the markup and the browser evaluates them. What is replaced is the presentation: native bubbles cannot be styled, vanish on their own, appear one at a time, and are not reliably announced.
Error summary — what a failed submit produces
There are 3 problems with this form
The single highest-value accessibility pattern in a form
Shown here as a static example so it can be read without racing a submit; the form below produces the real one. Without a summary, someone whose submit failed has no idea how many problems there are or where they are — they have to walk the entire form again, and with a screen reader that means listening to every field to find the two that are wrong.
What it has to get right:
- It appears only after a submit attempt, never on page load. A form that greets you with a list of errors before you have typed anything teaches you to ignore the list.
- Focus moves to it, so the user lands on the explanation. Without that, a failed submit silently returns them to the top of the page with no idea anything happened.
-
tabindex="-1"makes that possible without turning the summary into a permanent tab stop. - Every entry is a link to its field. A list that names problems without offering a way to reach them is a description of the work rather than help with it.
- The wording matches the message beside the field exactly. Two different descriptions of one problem makes the user reconcile them.
- The count is in the heading — "3 problems", not "Please correct the errors". Knowing how many there are is how someone decides whether to keep going.
Submit with the fields empty, or with an invalid email
Nothing is flagged until you press submit. Validating while someone types tells them off for not having finished, and people learn to ignore error styling that is always present — which is exactly the styling you need them to read later.
Do
- Summarise every problem at the top and move focus there.
- Link each summary entry to its field.
- Use the same wording in the summary and at the field.
- Say what to do, not just what is wrong.
- Clear the error as soon as the correction is valid.
Don’t
- Validate on every keystroke, or on first blur of an untouched field.
- Rely on the client alone — the server validates again, always.
- Say “Invalid input” or “Please match the requested format”.
- Remove the hint when the error appears.
Search and results
Four states, all part of the pattern: loading, results, nothing found, request failed. Live — type below. The one most often skipped is “failed”, and it is the one where the user most needs to be told what to do next.
Search and results — live
Searching…
Try fewer words, or search by owner instead.
Error: Search is unavailable
We could not reach the search service. Your query is still in the box — try again in a moment.
Keeping the stale list on screen while loading preserves context and avoids a layout collapse on every keystroke; it is made unclickable, because acting on data about to be replaced is worse than waiting. Type “error” to reach the failed state, and “try again” to recover from it — “the service is down” and “there is no such project” need different messages, and collapsing them into one leaves the user guessing.
Nothing yet — a different job from nothing found
No projects yet
A project holds documents, a budget and the people who can see it. Create one to get started.
Upload flow
Choosing files, seeing them checked, and recovering from a rejection. Live — choose a file; anything over 2 MB is rejected, and roughly one upload in five fails partway through, so both recovery paths are reachable without waiting for a real server to misbehave.
Upload flow — live, 2 MB limit
A rejection says why, and what to do
“Upload fehlgeschlagen” tells the user nothing they can act on. The message names the actual number, the actual limit, and a way forward. Someone who has just waited through a large upload on a phone connection has earned that much.
One failure never discards the others. The ones that worked stay uploaded — a flow that throws away successful work because the last file was too large makes the user redo all of it.
Every per-file button carries the filename in its accessible name. Three buttons called “Abbrechen” are three indistinguishable controls when read out of context.
Progress is announced at intervals, not continuously. The
<progress> element carries the
value for anything that polls it, while the
role="status" summary above updates in
steps — a live region reading a new percentage every frame is
unusable.
Multi-step form
One URL per step, rendered by the server, is the reference model — it survives a reload, a shared link, the back button and a failed script. What is below is the single-page enhancement over that, not a replacement for it.
Do
- Keep every answer when the user goes back.
- State the position in text — “Step 2 of 3”.
- Move focus to the new step’s heading and announce the change.
- Validate only the current step.
- Name what each “Change” link changes.
- Summarise everything before anything irreversible.
Don’t
- Discard input on Back — the user then stops checking answers.
- Let a
requiredfield in a hidden step block submission. - Leave focus on a button that is now hidden.
- Use a client-side wizard for anything long or valuable.
- Draw progress as dots with no text.
Derived output
A read-only value worked out from something the user entered. The generalised form of a pattern every product grows two or three of, and each one usually built wrong in the same few ways.
Five digits. Region and delivery zone are looked up automatically.
Postcode recognised
Try 10115, 80331 or 28217 — and then something invalid like 99999. A half-typed value is never reported as an error: resolution runs on blur, not on keystroke.
Do
- Render the derived value as output — a
<dl>, not an input. - Announce it politely when it appears.
- Clear it the moment the input changes.
- Resolve on the server; reference data does not belong in the browser.
Don’t
- Make it an editable field — the edit is discarded or conflicts.
- Treat “incomplete” as “invalid”.
- Leave a stale derived value beside a changed input.
- Ship a lookup table to the client.
Authentication
WCAG 2.2 3.3.8 Accessible Authentication rules out a lot of received
practice. Remembering a password is a cognitive function test, so the
page must support a password manager — which means correct
autocomplete tokens, no paste blocking,
and no code split across separate inputs.
The password field above is written as a bare
<input type="password">. The reveal
toggle beside it is standard behaviour on every password field
(.dds-password),
not something this pattern adds — which is why 3.3.8 cannot be missed by
forgetting to ask for it.
Verification code — one field, not six boxes
We sent it to the email address you signed in with. It expires in 10 minutes.
Six separate boxes look tidy and defeat autofill, paste and screen
readers at once. One field with
autocomplete="one-time-code" lets the
platform offer the code straight from the message.
Password reset — step 1: ask for the address
The response is the same either way
"If that address has an account, we have sent a code." Never "no account found". A reset form that distinguishes the two is a free account- enumeration oracle: anyone can test an address list against it and learn who has an account, which for a health service, a legal service or a dating service is a disclosure in itself.
The same applies to timing — respond in the same time in both cases, or the difference is measurable.
Password reset — step 2: the code, then the new password
A code, never a clickable link
The email carries a code the user types here — not a link that signs them in. A reset link is a bearer credential sitting in an inbox: it is forwarded, it is scanned and pre-fetched by mail security tooling, which silently consumes single-use tokens, and it appears in referrer headers and browser history. It also lands the user in whichever browser their mail client prefers, which is usually not the one they were using.
A typed code keeps the session where it started. It is also the more accessible option: it works when the mail client strips links, and it can be read out over the phone to someone who needs help.
Why the code is alphanumeric, and uppercase
[A-Za-z0-9]{6} with
autocapitalize="characters", and accepted in
either case. Six digits is 10⁶ combinations; six alphanumerics is about
2×10⁹, which is what makes a short code safe to leave valid for fifteen
minutes.
inputmode="text", not
numeric — a numeric keypad cannot type
letters, and a code field that will not accept the code in it is a dead
end on a phone. .dds-input-code sets a
monospace face with ligatures off, so 0 and
O, 1 and
l are distinguishable when transcribing.
autocomplete="one-time-code" stays on it, so
the platform can still offer the code from the message. One field, not six
boxes — the same rule as the sign-in code above.
Confirmation, without telling anyone off mid-word
Both password fields carry
autocomplete="new-password". Not
off, and not only on the first: that token is
how a password manager knows to generate and then to save, and setting it on
one field leaves the user retyping 24 random characters into the other.
The mismatch is reported once the confirmation is at least as long as the
first entry, or once the field is left — never on the second keystroke.
"Passwords do not match" is true of every confirmation field until the
moment it is not, and saying so immediately teaches people to ignore it.
The match state uses role="status", and the
text is only rewritten when it changes, so a screen reader does not repeat
it on every keypress.
minlength="12" and nothing else. No character-
class rules: they measurably push people towards
Password1! and are no longer recommended by
NIST or the BSI. Never block paste, or every password manager stops working
(WCAG 2.2 3.3.8 Accessible Authentication).
Do
- Use
autocomplete="username"andcurrent-password. - Leave the reveal toggle to
.dds-password— it is automatic. - Put a one-time code in ONE field with
one-time-code. - Give the same response whether or not an account exists.
Don’t
- Block paste — it breaks every password manager.
- Split a code across separate inputs.
- Reveal whether an email address is registered.
- Require a CAPTCHA with no accessible alternative.
Filtering
Composes the filter bar and chips with the results and empty states. The pattern’s own contribution is the rules — above all that the applied filters must always be visible.
2 of 18 projects match the current filters.
-
Harbour redevelopment
Approved · Ilva Bergström · 1.204 Dokumente
-
Kalvebod cycle bridge
Approved · Tomasz Wierzbicki · 471 Dokumente
Sidebar above 52rem of container width, stacked below — container-based, so it works inside a panel and not only on a full page. Filter state belongs in the URL, so a filtered view can be shared, bookmarked and returned to with the back button — and putting it there is the product's job, because the URL belongs to the product's router. DDS renders and announces the filter state; it does not navigate.
Conditional fields
Progressive disclosure inside a form. Toggled with the
hidden attribute, so hidden fields are
out of the tab order and out of the accessibility tree — hiding them
with CSS alone leaves invisible tab stops.
Focus is deliberately not moved into the revealed region. The user is working through the form in order and will arrive there next; stealing focus would skip whatever sits between.
Consent gate
A page-level, remembered opt-in for something that would
otherwise load unasked — a self-hosted analytics script. No new
component: .dds-banner as a non-modal
role="dialog", pinned to the bottom of
the viewport. The pattern is the choice, the memory of it, and the
DDS.consent contract the product's
script-injection code subscribes to.
Normally at the end of <body> and
position: fixed; shown
static here so it stays inside this
example. The gated script is not real — the line below stands in for
the product would inject the analytics script now
.
No decision yet — the gated script stays unloaded.
Decline is not the quiet option
Both buttons are real <button>s of
peer weight — data-dds-consent-set="denied"
is never a link and never .dds-button-subtle.
There is no dismiss control, Escape does nothing, and the bar is not
dismissible without a choice: under TTDSG §25, silence is not a yes.
The bar is non-modal and pointer-events: none
bar the card, so the page stays usable while the choice is pending —
doing nothing has to be a genuine third option. Focus moves into the
bar only when it is re-opened from Privacy choices
, and
returns there once a choice is made. It never traps.
DDS.consent.onChange('analytics', fn)
fires once immediately and on every later change, so the product
writes one branch that injects the script on
granted and covers both the first
decision and every return visit. A bumped
<meta name="dds-consent-policy">
makes every stored decision stale and re-opens the bar.