Accessibility
Accessibility is encoded in the tokens and the component APIs, not left to the person using them. This page documents what the system guarantees and what you still owe.
What the system guarantees
Contrast. Every semantic foreground/background pair clears WCAG 2.2 AA in both light and dark: 4.5:1 for body text, 3:1 for large text (19px bold / 23px regular and above) and non-text elements like borders and icons.
Focus. One ring, defined by --dt-focus-ring-* and applied through :focus-visible in tokens/base.css. It is 2px, offset 2px, and uses the primary colour, which is contrast- checked against every surface in the system. Pointer users never see it; keyboard users always do.
Touch targets. --dt-size-control-md is 40px and --dt-size-touch-target is 44px. WCAG 2.2 §2.5.8 sets the floor at 24px; 44px is the size people can actually hit.
Reduced motion. All four motion roles collapse to 0ms linear under prefers-reduced-motion: reduce. No component needs its own media query.
What you owe
Never remove a focus ring. Not with outline: none, not by overriding --dt-focus-ring-width to zero. If the ring looks wrong on a component, the component needs a different offset, not a removal.
Labels are not optional. Every icon-only control takes a required label prop. Every form field is associated with a <label>. Placeholder text is not a label, because it disappears the moment someone types.
Don't carry meaning in colour alone. An error state needs an icon or text, not just a red border. This is why theme-mono keeps feedback colours chromatic even though it strips the brand hue.
Keyboard contracts are part of the component. Each component's .md file documents its keyboard behaviour. If you compose components into a new pattern, the pattern needs a documented contract too.
Respect the heading hierarchy. Type roles are visual, not structural. --dt-text-heading-md on an <h4> is fine. Skipping from <h2> to <h5> because it looked right is not.
The pairs to check when theming
If you override any of these surfaces, verify the paired foreground with it.
| Surface | Foreground | Minimum |
|---|---|---|
--dt-surface-base | --dt-text-primary | 4.5:1 |
--dt-surface-base | --dt-text-secondary | 4.5:1 |
--dt-surface-base | --dt-text-tertiary | 4.5:1 |
--dt-surface-subtle | --dt-text-primary | 4.5:1 |
--dt-surface-action | --dt-text-on-action | 4.5:1 |
--dt-surface-action-danger | --dt-text-on-action-danger | 4.5:1 |
--dt-surface-inverse | --dt-text-inverse | 4.5:1 |
--dt-surface-brand | --dt-text-on-brand | 4.5:1 |
--dt-surface-brand-secondary | --dt-text-on-brand-secondary | 4.5:1 |
--dt-surface-brand-muted | --dt-text-on-brand-muted | 4.5:1 |
--dt-surface-scrim over a photo | --dt-text-on-scrim | 4.5:1, measured on the pixels (below) |
--dt-surface-selected | --dt-text-on-selected | 4.5:1 |
--dt-surface-success | --dt-text-on-success | 4.5:1 |
--dt-surface-warning | --dt-text-on-warning | 4.5:1 |
--dt-surface-base | --dt-border-default | 3:1 for meaningful borders |
--dt-surface-base | --dt-focus-ring-color | 3:1 |
Disabled states are exempt from the text contrast minimum under WCAG, but Dovetail still aims for 3:1, because a disabled control nobody can read is a usability problem even if it passes.
Contrast over imagery
Every pair above assumes a solid surface. Text over a photograph does not have one: a scrim's alpha says how much it darkens, not what is behind any particular line of text on any particular photo. One pass of --dt-surface-scrim can pass on a dark forest and fail on a snowfield. Measure the pixels instead of the tokens.
- Render the page at the widths you ship, with the real photograph in place.
- Hide the text (
visibility: hidden, so the layout does not move) and screenshot. - Sample the pixels in each text line's bounding box. Take the lightest one, not the average: the reader's eye stops on the worst run of letters, not the mean.
- Compute the contrast of
--dt-text-on-scrimagainst that pixel. Body text needs 4.5:1, large display text 3:1. - If it fails, strengthen the scrim where the text sits (a second gradient stop, a solid wash behind the caption), move the text, or crop the photo. Do not lighten the text or drop the scrim to "let the photo breathe".
Repeat it for every photo a template can receive. A CMS-driven hero needs a scrim that passes against the brightest image an editor might upload, which in practice means a solid scrim or a gradient that stays dark across the whole caption, not one that fades out halfway through the title.
Cover and Section with media both read --dt-text-on-scrim, which stays light in dark mode as well: a scrim over a photograph is dark whatever the page is doing, so the text on it must not flip with the page the way --dt-text-inverse does.
Testing
Automated checks catch roughly 30% of issues. The rest need a person.
- Tab through the entire screen. Every interactive element must be reachable, in a sensible order, with a visible ring.
- Operate it with the keyboard alone. No mouse, no trackpad.
- Run it at 200% browser zoom and 320px width.
- Turn on
prefers-reduced-motionand confirm nothing animates. - Check it in dark mode, where contrast regressions hide.