Skip to main content

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.


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

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

We only use this to confirm the request.

Format: 7K4M-92QX. Leave empty if you do not have one.

How should we reply? (required)

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

    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.

    1. Your details Current step
    2. Preferences Not started
    3. Review Not started

    Step 1 of 3

    Your details

    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 required field 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.

    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.

    Sign in

    Use the email address your account was created with.

    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

    Reset your password

    We will send a code to your email address.

    The address you signed up with.

    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

    Choose a new password

    Enter the code from the email, then your new password.

    Six characters, letters and numbers — for example K4M2QX. It expires in 15 minutes.

    At least 12 characters. Anything you like — length matters far more than which symbols are in it.

    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" and current-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.

    Applied: Status: Approved

    2 of 18 projects match the current filters.

    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.

    Where should we send the invoice?

    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.