---
title: "Spacing scale"
description: "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."
url: "https://opsinjs.pensievelabs.org/foundations/space/spacing-scale"
source: "https://opsinjs.pensievelabs.org/foundations/space/spacing-scale.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["4pt grid", "spacing steps", "gutter", "layout rhythm"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#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 [#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.

<SpaceSpecimen step="default" />

## Using it [#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.

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

**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](../shape/radius-scale.mdx) 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 [#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.

<TokenTable scope="space" />

## Accessibility impact [#accessibility-impact]

* **Separation between touch targets is a spacing decision with a conformance
  consequence.** The minimum gap is specified on
  [Density and touch](./density-and-touch.mdx), 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](../typography/dynamic-type.mdx).
* **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 [#related]

* [Density and touch](./density-and-touch.mdx) is where the scale meets a hard
  floor.
* [Radius scale](../shape/radius-scale.mdx) has the nesting arithmetic that uses
  these values.
* [Space tokens](./tokens.mdx) has the generated steps, roles and resolved values.
