Spacing

Every gap, pad and margin in Deck comes from one scale of 14 steps built on a 4px base. Seven of those steps have utilities. This page is the scale, the reason the utilities are a subset of it, and the reason to reach for gap before reaching for a margin.

One scale

The base unit is 4px, expressed in rem so it follows the reader's font size instead of overriding it. Somebody who has set their browser to 20px because 16px is uncomfortable gets a page whose spacing grows with the text. A scale written in px would give them larger text crammed into the same gaps, which is worse than not scaling at all.

The steps are not linear. They double and half-step — .25 / .5 / .75 / 1 / 1.25 / 1.5 / 2 / 2.5 / 3 / 4 / 5 / 6 rem — because the eye reads ratios rather than differences. The difference between 4px and 8px is obvious; the difference between 80px and 84px is invisible. A linear scale spends most of its steps on distinctions nobody can see.

Design tokens this component reads
Token Live Declared value
--space-0 0
--space-px 1px
--space-1 .25rem
--space-2 .5rem
--space-3 .75rem
--space-4 1rem
--space-5 1.25rem
--space-6 1.5rem
--space-8 2rem
--space-10 2.5rem
--space-12 3rem
--space-16 4rem
--space-20 5rem
--space-24 6rem

Two more tokens are fluid rather than fixed. --space-section is clamp(2.5rem, 1.4rem + 5.5vw, 6rem) and --space-gutter is clamp(1rem, .55rem + 2.2vw, 2rem). They are the page rhythm — the band of air around a section, and the breathing room at the edge of the viewport — and both are things that should be tighter on a phone and looser on a monitor without a media query picking where the line falls. .section and .container consume them for you.

The utilities are a subset, on purpose

Fourteen steps in the scale; seven with utilities: 0, 1, 2, 3, 4, 6, 8. There is no .p-5, no .mt-12, no .gap-24, and that is not an oversight waiting to be filled in.

The two halves of the scale get used by different things. The small end — 4px to 32px — is the distance between a label and its input, an icon and its text, a row and the next row. That is a nudge; it belongs to one place on one page, and typing it into the markup is quicker than naming it. The large end — 40px and up — is page rhythm: the band above a section, the inset of a hero. That is a decision about the whole page, it repeats, and it belongs in a rule with a name rather than scattered across forty elements as .mt-16.

So the seven steps cover the nudges and stop. If you find yourself wanting .p-12, the useful question is not "why is it missing" but "what is this element, and should it not be a .section, a .card, or a rule of your own reading var(--space-12)?" Every step of the scale is a public token whether or not it has a utility, and your own layer beats Deck with no !important:

@layer app.pages {
  .hero { padding-block: var(--space-12); }
}
Which steps of the scale have utilities
Step Token Value Utility
0 --space-0 0 .p-0, .gap-0, .mt-0 and the rest
px --space-px 1px token only — write var(--space-px) in your own rule
1 --space-1 .25rem .p-1, .gap-1, .mt-1 and the rest
2 --space-2 .5rem .p-2, .gap-2, .mt-2 and the rest
3 --space-3 .75rem .p-3, .gap-3, .mt-3 and the rest
4 --space-4 1rem .p-4, .gap-4, .mt-4 and the rest
5 --space-5 1.25rem token only — write var(--space-5) in your own rule
6 --space-6 1.5rem .p-6, .gap-6, .mt-6 and the rest
8 --space-8 2rem .p-8, .gap-8, .mt-8 and the rest
10 --space-10 2.5rem token only — write var(--space-10) in your own rule
12 --space-12 3rem token only — write var(--space-12) in your own rule
16 --space-16 4rem token only — write var(--space-16) in your own rule
20 --space-20 5rem token only — write var(--space-20) in your own rule
24 --space-24 6rem token only — write var(--space-24) in your own rule

Physical names, logical properties

The short utilities are named the way everyone expects and implemented the way Deck needs. .py-4 does not set padding-top and padding-bottom; it sets padding-block. .mt-4 sets margin-block-start. Nothing in Deck is written in physical properties, so an Arabic or Hebrew page mirrors completely from dir="rtl" with no second stylesheet.

That is also why there is no .ml-4 or .mr-4. Left and right are the two directions that actually flip, so naming them would build the bug in. The inline pair is spelled out instead.

Utility prefixes and the properties they set
Prefix Property In a left-to-right page
.p-paddingAll four sides
.px-padding-inlineLeft and right
.py-padding-blockTop and bottom
.pis-padding-inline-startLeft
.pie-padding-inline-endRight
.pbs-padding-block-startTop
.pbe-padding-block-endBottom
.mt-margin-block-startTop
.mb-margin-block-endBottom
.mis-margin-inline-startLeft
.mie-margin-inline-endRight

.mis-auto and .mie-auto do the most work of the set. .mis-auto pushes an item to the far end of a flex row — it is what .push in the navbar is underneath. .mx-auto centres a block by splitting the leftover inline space evenly.

mis-auto: one class on the item instead of a rule on the parent
StartPushed to the end
<div class="bar"><span class="badge">Start</span><span class="badge mis-auto">Pushed to the end</span></div>
<div class="bar"><span class="badge">Start</span><span class="badge mis-auto">Pushed to the end</span></div>

Why gap beats margin

When you are spacing a run of siblings, set gap on the parent rather than a margin on the children. Four reasons, in ascending order of how much time they cost you.

  1. The last one is wrong. .mb-4 on every child puts a margin under the final child too, so the group ends with a space that is not between anything. The usual fix is a :last-child reset, which is a rule you now have to write, name and remember.
  2. Margins collapse; gaps do not. Adjacent block margins merge into the larger of the two, so .mb-4 above and .mt-2 below give you 1rem rather than 1.5rem. That is correct CSS and almost never what anyone intended. gap has no collapsing rules at all.
  3. The spacing belongs to the group, not the item. A margin on a child is a claim about its surroundings made from inside it, so the same component spaces itself differently depending on where you drop it. A gap is set once by whatever is doing the arranging, which is the thing that actually knows.
  4. It survives reordering. Wrap a row, reverse it, remove a child, insert one — the gaps stay right, because there is a single value and the layout applies it. Margins have to be re-reasoned every time the order changes.

Deck's layout primitives all set gap from a --gap custom property, so most of the time you do not want .gap-* at all — you want .stack-3 or .cluster-tight, which set the same thing by name. .gap-* is for a flex or grid container you built yourself.

stack-2 sets --gap; nothing is spaced from the inside
First
Second
Third, with no trailing space under it
<div class="stack stack-2"> <div class="card"><div class="card-body">First</div></div> <div class="card"><div class="card-body">Second</div></div> <div class="card"><div class="card-body">Third, with no trailing space under it</div></div> </div>
<div class="stack stack-2">
  <div class="card"><div class="card-body">First</div></div>
  <div class="card"><div class="card-body">Second</div></div>
  <div class="card"><div class="card-body">Third, with no trailing space under it</div></div>
</div>

The exception is one element that needs to sit away from one specific neighbour — a back link above a heading, say. That is a real margin: it is about those two elements and not about a group, and .mt-6 is the right tool for it.

Safe areas

Three padding utilities read env(safe-area-inset-*) instead of the scale, for the rounded corners and the home indicator on a phone. They are the only spacing in Deck that changes with the device rather than the design, and they are zero on every screen without a notch — so adding one costs nothing where it is not needed.

.safe-x takes the larger of the gutter and the inset, so text never ends up tighter to the edge than the rest of the page. All three need viewport-fit=cover in the viewport meta tag, which the starter page and Deck::head() both emit.

Safe-area padding utilities
Class Declares Resolves to Source
.safe-top padding-block-start: env(safe-area-inset-top) — 08-mobile.css:123
.safe-bottom padding-block-end: env(safe-area-inset-bottom) — 08-mobile.css:124
.safe-x padding-inline: max(var(--space-gutter), env(safe-area-inset-left)) max(var(--space-gutter), env(safe-area-inset-right)) padding-inline: max(clamp(1rem, .55rem + 2.2vw, 2rem), env(safe-area-inset-left)) max(clamp(1rem, .55rem + 2.2vw, 2rem), env(safe-area-inset-right)) 08-mobile.css:125

Spacing that follows the container

.pad-cq and .gap-cq scale with the width of the nearest container rather than the viewport, using cqi units clamped to the ends of the scale. A card in a narrow sidebar takes the small value and the same card in a wide main column takes the large one, with no media query and no second class.

Both only apply inside an element marked .cq. Outside one they do nothing whatsoever, which is why the rows below lead with the selector that carries the rule rather than with the class on its own.

Container-relative spacing
Class Declares Resolves to Source
.pad-cq .cq :is(.pad-cq)
padding: clamp(var(--space-3), 4cqi, var(--space-8))
padding: clamp(.75rem, 4cqi, 2rem) 18-container.css:38
.gap-cq .cq :is(.gap-cq)
gap: clamp(var(--space-2), 2.5cqi, var(--space-6))
gap: clamp(.5rem, 2.5cqi, 1.5rem) 18-container.css:39

Padding

.p-* sets all four sides, .px-* the inline axis, .py-* the block axis.

Padding utilities on both axes
Class Declares Resolves to Source
.p-0 padding: 0 — 09-utilities.css:49
.p-1 padding: var(--space-1) padding: .25rem 09-utilities.css:49
.p-2 padding: var(--space-2) padding: .5rem 09-utilities.css:50
.p-3 padding: var(--space-3) padding: .75rem 09-utilities.css:50
.p-4 padding: var(--space-4) padding: 1rem 09-utilities.css:51
.p-6 padding: var(--space-6) padding: 1.5rem 09-utilities.css:51
.p-8 padding: var(--space-8) padding: 2rem 09-utilities.css:52
.px-0 padding-inline: 0 — 09-utilities.css:54
.px-1 padding-inline: var(--space-1) padding-inline: .25rem 09-utilities.css:54
.px-2 padding-inline: var(--space-2) padding-inline: .5rem 09-utilities.css:55
.px-3 padding-inline: var(--space-3) padding-inline: .75rem 09-utilities.css:55
.px-4 padding-inline: var(--space-4) padding-inline: 1rem 09-utilities.css:56
.px-6 padding-inline: var(--space-6) padding-inline: 1.5rem 09-utilities.css:56
.px-8 padding-inline: var(--space-8) padding-inline: 2rem 09-utilities.css:57
.py-0 padding-block: 0 — 09-utilities.css:59
.py-1 padding-block: var(--space-1) padding-block: .25rem 09-utilities.css:59
.py-2 padding-block: var(--space-2) padding-block: .5rem 09-utilities.css:60
.py-3 padding-block: var(--space-3) padding-block: .75rem 09-utilities.css:60
.py-4 padding-block: var(--space-4) padding-block: 1rem 09-utilities.css:61
.py-6 padding-block: var(--space-6) padding-block: 1.5rem 09-utilities.css:61
.py-8 padding-block: var(--space-8) padding-block: 2rem 09-utilities.css:62

One side at a time

Four families, all logical: inline start and end, block start and end. Twenty eight classes that exist so you never have to write a physical padding-left into a page that might be read right to left.

Single-side logical padding utilities
Class Declares Resolves to Source
.pis-0 padding-inline-start: 0 — 19-logical.css:41
.pis-1 padding-inline-start: var(--space-1) padding-inline-start: .25rem 19-logical.css:42
.pis-2 padding-inline-start: var(--space-2) padding-inline-start: .5rem 19-logical.css:43
.pis-3 padding-inline-start: var(--space-3) padding-inline-start: .75rem 19-logical.css:44
.pis-4 padding-inline-start: var(--space-4) padding-inline-start: 1rem 19-logical.css:45
.pis-6 padding-inline-start: var(--space-6) padding-inline-start: 1.5rem 19-logical.css:46
.pis-8 padding-inline-start: var(--space-8) padding-inline-start: 2rem 19-logical.css:47
.pie-0 padding-inline-end: 0 — 19-logical.css:48
.pie-1 padding-inline-end: var(--space-1) padding-inline-end: .25rem 19-logical.css:49
.pie-2 padding-inline-end: var(--space-2) padding-inline-end: .5rem 19-logical.css:50
.pie-3 padding-inline-end: var(--space-3) padding-inline-end: .75rem 19-logical.css:51
.pie-4 padding-inline-end: var(--space-4) padding-inline-end: 1rem 19-logical.css:52
.pie-6 padding-inline-end: var(--space-6) padding-inline-end: 1.5rem 19-logical.css:53
.pie-8 padding-inline-end: var(--space-8) padding-inline-end: 2rem 19-logical.css:54
.pbs-0 padding-block-start: 0 — 19-logical.css:56
.pbs-1 padding-block-start: var(--space-1) padding-block-start: .25rem 19-logical.css:57
.pbs-2 padding-block-start: var(--space-2) padding-block-start: .5rem 19-logical.css:58
.pbs-3 padding-block-start: var(--space-3) padding-block-start: .75rem 19-logical.css:59
.pbs-4 padding-block-start: var(--space-4) padding-block-start: 1rem 19-logical.css:60
.pbs-6 padding-block-start: var(--space-6) padding-block-start: 1.5rem 19-logical.css:61
.pbs-8 padding-block-start: var(--space-8) padding-block-start: 2rem 19-logical.css:62
.pbe-0 padding-block-end: 0 — 19-logical.css:63
.pbe-1 padding-block-end: var(--space-1) padding-block-end: .25rem 19-logical.css:64
.pbe-2 padding-block-end: var(--space-2) padding-block-end: .5rem 19-logical.css:65
.pbe-3 padding-block-end: var(--space-3) padding-block-end: .75rem 19-logical.css:66
.pbe-4 padding-block-end: var(--space-4) padding-block-end: 1rem 19-logical.css:67
.pbe-6 padding-block-end: var(--space-6) padding-block-end: 1.5rem 19-logical.css:68
.pbe-8 padding-block-end: var(--space-8) padding-block-end: 2rem 19-logical.css:69

Margin

Deliberately a smaller set than the padding one. There is no .m-4, only .m-0, because a margin on all four sides of something is nearly always a padding on its parent. The block pair covers "space this away from the thing above it", and the inline pair is there for the alignment tricks.

Margin utilities
Class Declares Resolves to Source
.m-0 margin: 0 — 09-utilities.css:64
.mx-auto margin-inline: auto — 09-utilities.css:73
.mt-0 margin-block-start: 0 — 09-utilities.css:65
.mt-1 margin-block-start: var(--space-1) margin-block-start: .25rem 09-utilities.css:65
.mt-2 margin-block-start: var(--space-2) margin-block-start: .5rem 09-utilities.css:66
.mt-3 margin-block-start: var(--space-3) margin-block-start: .75rem 09-utilities.css:66
.mt-4 margin-block-start: var(--space-4) margin-block-start: 1rem 09-utilities.css:67
.mt-6 margin-block-start: var(--space-6) margin-block-start: 1.5rem 09-utilities.css:67
.mt-8 margin-block-start: var(--space-8) margin-block-start: 2rem 09-utilities.css:68
.mb-0 margin-block-end: 0 — 09-utilities.css:69
.mb-1 margin-block-end: var(--space-1) margin-block-end: .25rem 09-utilities.css:69
.mb-2 margin-block-end: var(--space-2) margin-block-end: .5rem 09-utilities.css:70
.mb-3 margin-block-end: var(--space-3) margin-block-end: .75rem 09-utilities.css:70
.mb-4 margin-block-end: var(--space-4) margin-block-end: 1rem 09-utilities.css:71
.mb-6 margin-block-end: var(--space-6) margin-block-end: 1.5rem 09-utilities.css:71
.mb-8 margin-block-end: var(--space-8) margin-block-end: 2rem 09-utilities.css:72
.mis-auto margin-inline-start: auto — 19-logical.css:21
.mis-0 margin-inline-start: 0 — 19-logical.css:25
.mis-1 margin-inline-start: var(--space-1) margin-inline-start: .25rem 19-logical.css:26
.mis-2 margin-inline-start: var(--space-2) margin-inline-start: .5rem 19-logical.css:27
.mis-3 margin-inline-start: var(--space-3) margin-inline-start: .75rem 19-logical.css:28
.mis-4 margin-inline-start: var(--space-4) margin-inline-start: 1rem 19-logical.css:29
.mis-6 margin-inline-start: var(--space-6) margin-inline-start: 1.5rem 19-logical.css:30
.mis-8 margin-inline-start: var(--space-8) margin-inline-start: 2rem 19-logical.css:31
.mie-auto margin-inline-end: auto — 19-logical.css:22
.mie-0 margin-inline-end: 0 — 19-logical.css:32
.mie-1 margin-inline-end: var(--space-1) margin-inline-end: .25rem 19-logical.css:33
.mie-2 margin-inline-end: var(--space-2) margin-inline-end: .5rem 19-logical.css:34
.mie-3 margin-inline-end: var(--space-3) margin-inline-end: .75rem 19-logical.css:35
.mie-4 margin-inline-end: var(--space-4) margin-inline-end: 1rem 19-logical.css:36
.mie-6 margin-inline-end: var(--space-6) margin-inline-end: 1.5rem 19-logical.css:37
.mie-8 margin-inline-end: var(--space-8) margin-inline-end: 2rem 19-logical.css:38

Gap

gap applies to flex, grid and multi-column containers. On anything else it is inert, which makes .gap-3 on a plain <div> a silent no-op and the commonest way to lose ten minutes with these. Check that the parent is really a flex or grid container first.

Gap utilities
Class Declares Resolves to Source
.gap-0 gap: 0 — 09-utilities.css:38
.gap-1 gap: var(--space-1) gap: .25rem 09-utilities.css:39
.gap-2 gap: var(--space-2) gap: .5rem 09-utilities.css:40
.gap-3 gap: var(--space-3) gap: .75rem 09-utilities.css:41
.gap-4 gap: var(--space-4) gap: 1rem 09-utilities.css:42
.gap-6 gap: var(--space-6) gap: 1.5rem 09-utilities.css:43
.gap-8 gap: var(--space-8) gap: 2rem 09-utilities.css:44
.gap-cq .cq :is(.gap-cq)
gap: clamp(var(--space-2), 2.5cqi, var(--space-6))
gap: clamp(.5rem, 2.5cqi, 1.5rem) 18-container.css:39

When not to use these

  • Not for the space between siblings. That is .stack-*, .cluster or .grid, each of which sets a gap for you. A column of .mb-4 is a stack written the long way.
  • Not for page rhythm. .section already applies --space-section, and it is fluid. Replacing it with .py-8 pins the band at 2rem on a 27 inch monitor.
  • Not for the viewport edge. .container applies --space-gutter on the inline axis. Adding .px-4 on top of it double-pads the page.
  • Not to fix a component. If a card needs a nudge to look right, the card is wrong, and every other card on the site is wrong the same way. Fix it once in your own layer.
  • Not as .ml-* or .mr-*. Those do not exist. Use .mis-* and .mie-*, which flip correctly when the page does.