Target size and motor accessibility
Why the floor here is 44 CSS pixels rather than the WCAG 2.2 minimum of 24, and what else a shaking hand needs.
What we guarantee
Every interactive target in the product theme has a minimum hit area of 44 CSS
pixels on both axes. The value lives in the token --opsin-target-minimum,
which is generated from tokens/space.json and applied as a backstop in
app/product.css. Holding the value in a token puts it one edit away from being
audited, and the density setting deliberately cannot change it.
The token is 2.75rem, not 44px, and the unit is doing work. A reader who
enlarges text changes the root font size, so a rem floor grows with the type it
surrounds while a pixel floor would stay put and let the label outgrow the
control. 44 CSS pixels is therefore the figure at the default root size and the
smallest the floor ever gets, not a size it is held to.
Why 44 and not 24
WCAG 2.2 has two target-size criteria. SC 2.5.8 Target Size (Minimum) is level AA and asks for 24×24 CSS pixels, with exceptions for inline targets, spacing and user-agent defaults. SC 2.5.5 Target Size (Enhanced) is level AAA and asks for 44×44. Apple's Human Interface Guidelines have long recommended a 44×44 pt minimum tappable area; Android's guidance is 48 dp. We ship the AAA figure as the floor, for three reasons specific to this domain:
- The reading context is bad. Consumer health apps are used one-handed, in corridors, in waiting rooms, while holding a child, and immediately after receiving news that raises the reader's heart rate. Pointer accuracy under stress is worse than pointer accuracy at a desk.
- The user population skews towards motor impairment. People managing a long-term condition are, as a group, more likely to be older and more likely to have tremor, arthritis, neuropathy or reduced grip strength than the general population of a consumer app. Designing to the minimum designs for the least affected reader. (This is our reasoning, not a cited finding. See below.)
- The cost of a mis-tap is asymmetric. Mis-tapping a navigation item costs a second. Mis-tapping "log this reading" next to "delete this reading", or tapping "dismiss" on an alert you meant to open, costs data or safety.
Two further guarantees:
- Density never shrinks targets.
[data-density="compact"]moves--spacingonly. Type size and the target minimum are untouched, so "compact" means "less whitespace", never "harder to hit". - Nothing essential requires a path gesture. Any drag, swipe, pinch or multi-point interaction must have a single-pointer alternative, which is SC 2.5.1 and SC 2.5.7.
What you own
Spacing between adjacent targets. Size is not the whole story: two 44 px
targets with zero gap in a list of daily readings still produce mis-taps, because
the boundary is invisible. The system does publish one number here.
--opsin-target-separation is 8 px, or 0.5rem, and its token scopes it exactly:
it is the minimum gap between two adjacent targets whose visible boxes are
smaller than 44 px, a floor for undersized controls only. Two controls that both
meet the 44 px minimum fall outside that scope, so the 8 px floor does not settle
the gap between them. What settles that gap is the cost of a mis-tap: give
adjacent destructive and constructive actions more separation than the floor, and
never place "delete" adjacent to "save".
Density and touch carries the same
rule from the spacing side.
Where you put the targets. A 44 px control in the top-left corner of a large phone is not reachable one-handed. Primary actions in a patient-facing app belong in the lower half of the screen. See Layout.
Timing. A toast with an action, an auto-advancing carousel and a confirmation that dismisses itself all take themselves away before a slow reader arrives, which gives each of them an effective target size of zero. SC 2.2.1 is yours.
Sliders and scrubbers. A slider is the most common motor-accessibility failure in health UI, because a value picked by dragging is a value that a shaking hand cannot set precisely. Any slider must be accompanied by a way to enter the number, which is why ReadingInput exists alongside Slider rather than behind it. Both ship, and neither has been reviewed.
How to check
Measure the hit area, not the ink
In developer tools, hover the element and read the box the browser reports, then subtract nothing. A 20 px icon inside a 44 px button passes. A 44 px icon with a click handler on the SVG and 0 padding also passes. A 24 px chip with a 44 px visual halo drawn by a pseudo-element that does not receive pointer events does not.
Check the exception you are relying on
SC 2.5.8 exempts inline targets, targets with sufficient spacing, and user-agent-default controls. If you are below 44 px and think you are fine, name the exception out loud. Most of the time the honest answer is that no exception applies.
Try it with the wrong hand
Complete the primary task one-handed with your non-dominant hand, walking. This is a crude proxy for reduced dexterity and it finds reachability problems that no measurement finds.
Remove every gesture
Turn off the ability to swipe and drag. You can do that mentally, or by testing with a switch-access or keyboard-only setup. Then confirm every action is still available. Failure looks like a log entry that can only be deleted by swiping a row.
Measured results
scripts/build-registry.mts. Run pnpm run generate and reload.Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.
The intended generated artefact is a per-component measured hit area at each
density, in both themes, taken from a rendered (view) route. Nothing publishes
that table, and scripts/capture-registry.mts no-ops in a normal clone rather
than producing a fabricated one, because Playwright is in no package.json.
The measurement itself does now happen, in one place.
.github/workflows/nightly.yml installs Playwright at job time and runs
scripts/check-a11y.mts --layout, which loads every built /view route at
390x844 and fails on any interactive element whose border box is under 44x44. It
is a gate rather than an artefact: it names the boxes that failed and writes down
nothing about the ones that passed, it cannot see a hit area expanded by a
pseudo-element, and it runs on a schedule rather than on a pull request.
Known gaps
- There is no published per-component measured table. The nightly layout gate described above does measure, and it fails any interactive element under 44 by 44, but it publishes no artefact, it cannot see a hit area a pseudo-element has expanded, and it runs on a schedule rather than on every pull request. So the token is enforced somewhere, while the per-component table at each density this page wants is still not produced.
- Adjacent-target separation has a floor but no number for asymmetric actions.
--opsin-target-separationpublishes an 8 px floor for targets whose visible boxes are under 44 px. The prose rule then asks for more than a floor whenever two adjacent actions carry asymmetric consequences, and that second case has no published number. We would rather say so than invent one. - There is no shipped alternative for path-based gestures, because there are no shipped gestures.
Updates to this page
Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.
Colour independence
Clinical status must survive greyscale, colour vision deficiency and a photocopier. The word is what makes that true.
Text resizing and zoom
The 200% text check and the 400% reflow check are different tests that fail in different ways. It also says why a truncated number is a safety defect.