opsinjs
FoundationsToken familiesSpace and density

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.

TokenWhat it controlsUsed by
--opsin-space-00rem0px.no component
--opsin-space-10.25remGap 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.5remGap 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.75remInner 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-41remThe 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.25remCard inner padding on a phone.avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker
--opsin-space-61.5remCard 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-82remGap between sections within a screen.avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper
--opsin-space-102.5remSpace above a section heading.avatar, sheet, skeleton, switch
--opsin-space-123remGap between major regions of a screen.avatar, trend-sparkline
--opsin-space-164remTop of a screen below the safe area; the space a consent sheet leaves above its first question.dialog, skeleton
--opsin-space-205remEmpty-state vertical rhythm.no component
--opsin-space-246remThe largest step. Beyond this, use a layout, not a gap.no component
--opsin-space-px0.0625remHairline borders only.body-map, trend-sparkline
--opsin-space-0-50.125remOptical 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.75remThe 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-comfortable3remThe default for a primary action in the product theme.dialog
--opsin-target-generous3.5remA single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost.no component
--opsin-target-separation0.5remThe 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-phone16pxThe screen gutter in the phone responsive mode.no component
--opsin-gutter-tablet24pxThe screen gutter in the tablet responsive mode.no component
--opsin-gutter-wide32pxThe screen gutter in the wide responsive mode.no component
--opsin-measure-tight45chA caption or a legend.dialog, empty-state, goal-ring, popover, tooltip
--opsin-measure-comfortable66chThe 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-wide80chCode and machine output only.no component
--opsin-graphic-dial17remThe 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.
  • 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.

On this page