Density and touch
A 44pt floor measured on the interactive area rather than the visual box, the separation rule that stops adjacent mis-taps, and where a primary action belongs.
Overview
The most common physical failure in a consumer health app is not a crash. It is somebody tapping the wrong thing: dismissing an alert they meant to open, logging a reading in the wrong unit, deleting an entry they meant to edit. It happens because the control was small, or because the wrong control was next to it, or because it was in the part of the screen a thumb has to stretch for.
opsinjs sets a floor: any control a person taps is at least 44 points on both axes, measured on the interactive area rather than the visual box. That figure is the long-standing platform guidance for touch interfaces and it sits above the WCAG 2.2 SC 2.5.8 minimum of 24 CSS pixels. The two numbers come from different places, and Target size and motor says which is which.
The floor is a floor, not a target. Controls that are frequently used, used in a hurry, or destructive should be comfortably above it.
How it works
The measurement is the interactive area, not the ink. A 20-point icon inside a 44-point button passes. A 44-point icon with no padding and a 20-point hit area does not. What is measured is what responds to a tap.
The product theme backstops this: buttons, elements with a button role, checkboxes
and radios all carry a minimum block and inline size from
--opsin-target-minimum. That is a safety net for something that slipped
through, not a substitute for sizing a control properly. A backstop that
stretches a control to the floor may produce a layout nobody intended.
Corners take area away. A rounded control has less tappable area on its diagonals than its bounding box implies, so the floor is measured on the real shape. This is one reason small controls do not take large radius rungs. See Radius scale.
Separation is a second, independent requirement. Two 44-point controls touching each other still produce mis-taps, because a finger contact patch is larger than the point it registers and people aim at the middle of what they can see. Adjacent interactive elements are separated by at least one step of the spacing scale, and controls with asymmetric consequences, such as save next to delete or open next to dismiss, are separated by more or are not adjacent at all.
Reach is not uniform. On a phone held in one hand, the lower half of the screen is comfortable, the top corners require a grip change, and the far top corner requires two hands or a stretch. So: the primary action sits low; navigation between screens sits low; destructive and irreversible actions do not sit where a thumb rests by default. This is opinion informed by the platform guidance rather than a measured result, and it is stated as such.
Hover is not a target. Anything discoverable only by hovering is undiscoverable on a phone. Any information behind a hover is also available on tap and on focus.
Using it
Measure the hit area, not the design. The check is one line in the inspector and it catches most failures: select the control, read the computed box, compare to the floor at the current text size.
Do
A dismiss control on an alert banner sized to the floor, with its icon centred inside it and a full step of separation from the banner's primary action. Reaching for one cannot land on the other.
Don’t
A 24-point close glyph tucked into the corner, immediately beside the "call your GP" action. The two most consequentially different actions on the screen are adjacent and one of them is below the floor.
Never shrink a control to fit. If a row is too crowded, remove something or stack it. A control below the floor is a control somebody will miss, and on a health screen the thing they miss may be the escalation.
Do not extend a hit area invisibly across a gap. Padding a control so its tappable area overlaps its neighbour's visible space produces taps that appear to land on one thing and activate another. That is worse than a small target, because the reader cannot see why it happened.
Keep the floor at every text size. The floor holds at 1× and at 1.3×. A hit area grows with its label; it never shrinks to accommodate one.
Give destructive actions friction, not distance alone. Distance reduces accidental taps; confirmation handles the ones that get through. On anything that removes a reading somebody recorded, both apply.
Tokens
--opsin-target-minimum, the minimum separation step and the per-mode density
values are generated from tokens/space.json by scripts/build-tokens.mts.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-space-00rem | 0px. | no component |
| --opsin-space-10.25rem | Gap between an icon and its label. | badge, button, callout, care-card, checkbox, combobox, dialog, dose-tracker, empty-state, field, goal-ring, log-sheet, menu, questionnaire, radio-group, range-bar, result-card, scale-input, scroll-area, segmented-control, select, sheet, slider, source-citation, status-pill, stepper, surface, symptom-picker, tab-bar, tabs, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-space-20.5rem | Gap between tightly related lines. For the separation between two interactive targets see targets.separation, which is the same number and carries its scope. | alert-banner, avatar, badge, body-map, button, callout, combobox, consent-sheet, dialog, disclaimer-note, dose-tracker, empty-state, field, icon-button, log-sheet, menu, metric-tile, number-field, popover, progress, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, scale-input, score-dial, scroll-area, segmented-control, select, sheet, skeleton, slider, spinner, status-pill, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, trend-sparkline, visually-hidden |
| --opsin-space-30.75rem | Inner padding of a compact control. | accordion, alert-banner, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, empty-state, field, log-sheet, menu, metric-tile, popover, progress, radio-group, reading-input, result-card, scroll-area, segmented-control, select, sheet, slider, status-pill, stepper, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, value |
| --opsin-space-41rem | The default gap between elements inside a card. | accordion, avatar, badge, button, callout, care-card, consent-sheet, dialog, divider, empty-state, goal-ring, link, log-sheet, menu, popover, range-legend, result-card, scale-input, score-dial, segmented-control, sheet, surface, switch, tabs, timeline-entry, toast |
| --opsin-space-51.25rem | Card inner padding on a phone. | avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker |
| --opsin-space-61.5rem | Card inner padding on a wide screen; gap between cards. | avatar, card, dialog, disclaimer-note, empty-state, field, log-sheet, skeleton, stepper, switch, tab-bar, trend-sparkline |
| --opsin-space-82rem | Gap between sections within a screen. | avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper |
| --opsin-space-102.5rem | Space above a section heading. | avatar, sheet, skeleton, switch |
| --opsin-space-123rem | Gap between major regions of a screen. | avatar, trend-sparkline |
| --opsin-space-164rem | Top of a screen below the safe area; the space a consent sheet leaves above its first question. | dialog, skeleton |
| --opsin-space-205rem | Empty-state vertical rhythm. | no component |
| --opsin-space-246rem | The largest step. Beyond this, use a layout, not a gap. | no component |
| --opsin-space-px0.0625rem | Hairline borders only. | body-map, trend-sparkline |
| --opsin-space-0-50.125rem | Optical nudges. Not a layout step. | badge, dose-tracker, radio-group, range-legend, result-card, scroll-area, segmented-control, status-pill, stepper, switch, tab-bar, tabs, term, trend-sparkline |
| --opsin-target-minimum2.75rem | The opsinjs floor for any interactive control, applied to the hit area rather than to the visible box. | accordion, body-map, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, field, icon-button, menu, metric-tile, number-field, popover, questionnaire, radio-group, reading-input, scale-input, segmented-control, select, sheet, slider, source-citation, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-target-comfortable3rem | The default for a primary action in the product theme. | dialog |
| --opsin-target-generous3.5rem | A single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost. | no component |
| --opsin-target-separation0.5rem | The minimum gap between two adjacent targets whose visible boxes are smaller than 44px. It is a floor for undersized targets only. Two targets that both meet the 44px minimum are outside its scope, and separating those is governed by the differing cost of a mis-tap between them rather than by a fixed gap. | alert-banner, card, care-card, consent-sheet, result-card |
| --opsin-gutter-phone16px | The screen gutter in the phone responsive mode. | no component |
| --opsin-gutter-tablet24px | The screen gutter in the tablet responsive mode. | no component |
| --opsin-gutter-wide32px | The screen gutter in the wide responsive mode. | no component |
| --opsin-measure-tight45ch | A caption or a legend. | dialog, empty-state, goal-ring, popover, tooltip |
| --opsin-measure-comfortable66ch | The maximum line length for prose anywhere in the product, including a disclaimer nobody wants to read. | disclaimer-note, result-card, score-dial, sheet, source-citation, term |
| --opsin-measure-wide80ch | Code and machine output only. | no component |
| --opsin-graphic-dial17rem | The maximum width of the ScoreDial arc. This is 16em against the product body of 1.0625rem, which is 17rem and lands on the 4px grid at 68 steps, and it yields to w-full when the column is narrower. | goal-ring, score-dial |
| --opsin-safe-topenv(safe-area-inset-top, 0px) | The top safe-area inset, for installed web apps where browser chrome does not protect the edge. | no component |
| --opsin-safe-rightenv(safe-area-inset-right, 0px) | The right safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
| --opsin-safe-bottomenv(safe-area-inset-bottom, 0px) | The bottom safe-area inset, for installed web apps where browser chrome does not protect the edge. | dialog, sheet |
| --opsin-safe-leftenv(safe-area-inset-left, 0px) | The left safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
Accessibility impact
- WCAG 2.2 SC 2.5.8 Target Size (Minimum) requires 24 by 24 CSS pixels, with an exception where sufficient spacing exists. opsinjs sets a substantially higher floor and does not use the spacing exception, so a control that meets the opsinjs floor meets the criterion with room to spare. The distinction between the two figures matters when a product is being audited, and it is set out on Target size and motor.
- This is a motor-accessibility requirement first. Tremor, reduced dexterity, arthritis, neuropathy and using a phone one-handed while holding something else all reduce pointing precision. In a health product, a meaningful share of readers are in at least one of those categories, and several are conditions the product itself may be for.
- Separation matters as much as size, and it is the half that gets dropped. Two adjacent controls that individually pass can together produce a target that is functionally impossible to hit reliably.
- The floor must survive text scaling. A control whose hit area is defined by its label survives; one with a fixed pixel size does not, and it fails for the readers most likely to have set a larger size.
- Every component page publishes its measured target sizes, and a regression against the floor fails the build rather than appearing in a report nobody reads.
Related
- Responsive modes shows how reach and density change as the container grows, and why nothing gets smaller in the process.
- Target size and motor has the conformance position and the gesture-alternative requirement.
- Layout covers where the primary action sits, and the safe-area contract that keeps it reachable.