Space and density
The case for a deliberately generous scale in a product people use one-handed while worried, what it costs, and the four responsive modes that decide what fits.
Overview
opsinjs is deliberately roomier than most design systems, and roomier than the
documentation site you are reading. The docs chrome is shadcn's lyra, which is
dense and square because it is a reading surface for a developer at a desk. The
product theme is the opposite, and the divergence is explained on
Lyra and the docs chrome.
The mechanism is a single value. Tailwind derives its whole spacing scale from
--spacing; the product theme raises it above the default, and every padding,
gap and margin in the system moves with it. Nothing is re-tuned per component.
The bet is worth stating honestly, because it has a real cost. Generous spacing means fewer things fit on a screen, which means more scrolling. The reason opsinjs takes that trade anyway is that the two sides are not symmetrical. A reader who has to scroll loses a second. A reader who taps the wrong control because two 32-pixel targets were 4 pixels apart has logged a reading they did not take, or dismissed an alert they did not read. They are doing this one-handed, on a bus, possibly with cold hands, possibly at seventy, and possibly while frightened. Density is a trade of somebody's second against somebody else's mistake.
There is a second, quieter reason. Space is how an interface groups things without drawing anything. On a screen where colour is already fully committed to two axes and cannot be spent on grouping, proximity is one of the only grouping tools left.
How it works
Four pages sit under this one: the spacing scale and the named role of each step; Density and touch, which is the 44pt floor and target separation; Responsive modes, which is the four-mode system and the container-query rule; and the generated tokens.
Three rules run across all four.
1. One root value sets the density. Every step is derived from --spacing.
Changing it changes the whole product's character in one place, which is also
what makes the docs chrome and the product theme genuinely different systems
rather than two sets of hand-tuned numbers.
2. Space is measured between the things people see, not between boxes. Two elements separated by 8 pixels of margin plus 12 pixels of their own internal padding are 20 pixels apart to a reader and 8 pixels apart to a stylesheet. When a rule in opsinjs specifies a separation, it means the perceived one. This matters most for the minimum gap between touch targets.
3. Wide screens get more of the story, not bigger type. This is the rule most often broken and the one with the largest effect on how a health product feels on a tablet. It has its own page: Responsive modes.
Using it
Use the step, not a number. Every value comes from the scale. A one-off 13px
gap is invisible in review, breaks the nesting arithmetic that
radius depends on, and does not move when the
product's density is retuned.
Group by proximity before you group by anything else. A border, a background or a card is a heavier tool. If two things belong together, put them closer to each other than to everything else and see whether you still need the box.
Do
A reading, its unit and its timestamp set close together with a larger gap to the next metric. The grouping is obvious and no boxes were drawn, so the screen stays quiet.
Don’t
Even spacing between all five elements, with a card outline round each pair to explain what belongs to what. The outlines are doing work that space should have done, and the screen now has four more lines competing with the numbers.
Do not tighten spacing to make something fit. If a screen does not fit, it is doing too much. Tightening it moves the failure from "this needs editing" to "this reader mis-tapped", which is worse and harder to see.
Expect space to grow with text. At a 1.3× text-size multiplier, padding derived from the scale grows too. A layout that only fits because the gaps were fixed in pixels breaks for exactly the readers who most need it not to. See Dynamic Type.
Tokens
The root value, the scale steps and their named roles are generated from
tokens/space.json by scripts/build-tokens.mts. The full table with rendered
specimens is on Space tokens.
| 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
- Target size and target separation are both WCAG matters. SC 2.5.8 Target Size (Minimum) sets a floor and offers a spacing exception; opsinjs sets a higher floor and does not rely on the exception. The numbers and the reasoning are on Density and touch.
- Space is the grouping channel that survives everything. It works in greyscale, in a screenshot, at 200% zoom and under every colour-vision difference. It does not survive a screen reader. That is why proximity is always backed by structure, headings and grouping semantics rather than left to do the job alone.
- Generous spacing is a cognitive-accessibility measure. Fewer elements competing in the same visual area is less to parse, and a health screen is frequently read under stress. This is opinion informed by practice rather than a measured result, and it is stated as such.
- Spacing must scale with text. Padding that stays fixed while type grows produces cramped, then overlapping, then clipped layouts, in that order, as the multiplier rises.
- Density is never a per-user setting in opsinjs. Some systems offer a compact mode; a compact mode in a patient-facing health product is a setting that lets somebody make their own targets too small. The preview controls on this site expose density so you can see the effect, not so the product can ship it.
Related
- Density and touch covers the 44pt floor, separation and thumb reach.
- Responsive modes says what happens as the container grows, and why the answer is not "everything gets bigger".
- Layout is the shell all of this space sits inside, and holds the safe-area contract.