---
title: "Target size and motor accessibility"
description: "Why the floor here is 44 CSS pixels rather than the WCAG 2.2 minimum of 24, and what else a shaking hand needs."
url: "https://opsinjs.pensievelabs.org/accessibility/target-size-and-motor"
source: "https://opsinjs.pensievelabs.org/accessibility/target-size-and-motor.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["44pt", "2.5.8", "2.5.5", "tremor", "mis-tap"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#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 [#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:

1. **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.
2. **The user population skews towards motor impairment.*&#x2A; 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. &#x2A;(This is our reasoning, not a cited finding. See
   below.)*
3. **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.

<ResearchNote evidence="opinion" date="2026-09-02">
  The 24 px and 44 px figures are the published WCAG 2.2 AA and AAA thresholds
  and the 44 pt figure is Apple's long-standing HIG recommendation; those are
  checkable. The argument that this particular population justifies the AAA
  figure as a floor is **our opinion**. We have not run a study on it and we do
  not cite one. What would change our mind: task-level error-rate data from a
  consumer health product comparing 24 px and 44 px targets in the states that
  matter. Those states are one-handed, in motion and under time pressure.
</ResearchNote>

Two further guarantees:

* **Density never shrinks targets.** `[data-density="compact"]` moves
  `--spacing` only. 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 [#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](../foundations/space/density-and-touch.mdx) 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](../foundations/layout.mdx).

**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](../components/reading-input.mdx) exists
alongside [Slider](../components/slider.mdx) rather than behind it. Both ship,
and neither has been reviewed.

## How to check [#how-to-check]

<Steps>
  ### Measure the hit area, not the ink [#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 [#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 [#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 [#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.
</Steps>

## Measured results [#measured-results]

<NoDataYet script="scripts/build-registry.mts" />

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 [#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-separation` publishes 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 [#updates-to-this-page]

<Reviewed />
