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:
| Role | Separates |
|---|---|
hairline | Elements that are one thing, such as a value and its unit |
tight | A label from the value it labels |
close | Rows within a group |
default | Groups within a section; the inside padding of most containers |
section | Sections within a screen |
screen | The screen's outer gutter, before safe-area insets are added |
page | Between 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.
| 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
- 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.
Related
- Density and touch is where the scale meets a hard floor.
- Radius scale has the nesting arithmetic that uses these values.
- Space tokens has the generated steps, roles and resolved values.
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.
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.