opsinjs
FoundationsToken familiesSpace and density

Spacing scale

A 4pt-derived scale with a named role for each step, so a gap is chosen by what it separates rather than by how it looks.

Overview

The spacing scale is a 4-point rhythm scaled by a single root value. Four points is the interval the platforms opsinjs targets are built on, it divides cleanly at every common display density, and it is coarse enough that adjacent steps are visibly different. That is what makes a scale a decision aid rather than a list of numbers.

What opsinjs adds on top is a set of named roles. The numeric steps exist and are usable, but most spacing decisions should be made by naming the relationship rather than by picking a size. The relationship might be the gap between a label and its value, or the gutter at the edge of the screen. A named role is reviewable: somebody can disagree with "the gap between a label and its value is too tight" in a way they cannot disagree with "step 3".

How it works

The scale. Tailwind derives its entire spacing system from --spacing, so p-4 means four units of whatever that root is. The product theme sets that root to 0.25rem in app/product.css, which is the 4-point grid at a 16px font size, so a scaled step lands on the same pixel a fixed token would: p-5 resolves to 20px, exactly as p-opsin-5 does. A denser layout scales the root down rather than rewriting a single utility class.

A card takes the scaled step, a modal takes the fixed token. The inside padding of a card-like surface uses the density-scaled step, p-4 or p-5, so it tightens when a reader chooses a denser layout. A modal surface such as a dialog or a sheet uses the fixed token, p-opsin-4 or p-opsin-5, which density never moves, because a surface laid over the screen should hold its inset while the content behind it reflows. At the default density the two spellings are the same number, so a results screen that stacks a card, a result card and a dialog insets all three on one published step: 16px at p-4 and p-opsin-4, 20px at p-5 and p-opsin-5.

The compact density leaves the 4px grid, and the page says so rather than hiding it. Density is one multiplier on --spacing, and the compact setting takes the root to 0.21875rem, where p-4 renders 14px and p-5 renders 17.5px. No single multiplier can keep both steps on the grid at every density: for p-5 to stay on it the root must be a multiple of 0.05rem, for p-4 it must be a multiple of 0.0625rem, and 0.25rem is the smallest value that satisfies both, which is why it is the default. The 4px guarantee therefore covers the fixed --opsin-space-* steps, which density leaves alone, and the density-scaled Tailwind scale is the stated exception.

The named roles. Each maps to a step, and the map is generated:

RoleSeparates
hairlineElements that are one thing, such as a value and its unit
tightA label from the value it labels
closeRows within a group
defaultGroups within a section; the inside padding of most containers
sectionSections within a screen
screenThe screen's outer gutter, before safe-area insets are added
pageBetween major blocks on a wide-mode layout

The roles are what components consume. If a component reaches for a numeric step directly it is making a decision the system could have made, and the next person to adjust the product's density will have to find it.

Space is proportional, not absolute. Because everything derives from one root, and the root is expressed relative to the font size, spacing grows with the reader's text-size multiplier. That is intentional: a layout whose gaps stay fixed while its type grows becomes cramped and then broken as the multiplier rises.

Using it

Name the relationship, then take its role. "This is the gap between a group and the next group" is a decision anyone can review. "This is 12 pixels" is not.

Use one step of difference to separate, two to group. The perceptual rule that makes proximity work is contrast between gaps, not the size of any one gap. If the space inside a group and the space between groups are within one step of each other, the grouping does not read.

Do

Rows inside a metric group at close, groups separated by section. The difference is large enough that the structure is obvious at a glance, without a single divider.

Don’t

Everything at default with horizontal rules between groups. The rules are compensating for spacing that does not distinguish anything, and on a screen of numbers every extra line competes with the digits.

Do not use spacing to hit a pixel target. Adding a step to make a card land at a round height is how a scale becomes decorative. If the height matters, the container should be sized, not padded.

Space and radius are arithmetically linked. The nesting rule on Radius scale subtracts the padding between two boxes from the outer radius. Changing a padding therefore changes a radius, and both should come from the scale so the relationship survives.

Negative space is not free space. A gap large enough that two elements no longer read as related is a gap that has changed the meaning of the screen. That is usually what you want at section and never what you want at tight.

Tokens

The root value, the numeric steps, the named roles and the mapping between them are generated from tokens/space.json by scripts/build-tokens.mts. Specimens render from the generated values.

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

  • Separation between touch targets is a spacing decision with a conformance consequence. The minimum gap is specified on Density and touch, and it is a floor rather than a suggestion because an adjacent mis-tap is silent and, on a logging or dismissal control, destructive.
  • Proximity is the only grouping channel that survives colour-vision differences and greyscale, so it does real accessibility work. It does not survive a screen reader, which is why grouping is always also expressed in the document structure.
  • Spacing must scale with text or the layout fails at exactly the wrong moment. Because the scale is proportional, a component that uses it survives a 1.3× multiplier; a component that hard-codes a gap does not. See Dynamic Type.
  • Enough space is a cognitive load measure, particularly on screens read under stress. Stated as opinion informed by practice, not as a measured result.
  • Reflow at 400% zoom depends on the outer gutter behaving proportionally. A fixed gutter at that zoom level consumes most of the viewport; a derived one does not.

On this page