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.
| 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); }
}
| 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.
| Prefix | Property | In a left-to-right page |
|---|---|---|
.p- | padding | All four sides |
.px- | padding-inline | Left and right |
.py- | padding-block | Top and bottom |
.pis- | padding-inline-start | Left |
.pie- | padding-inline-end | Right |
.pbs- | padding-block-start | Top |
.pbe- | padding-block-end | Bottom |
.mt- | margin-block-start | Top |
.mb- | margin-block-end | Bottom |
.mis- | margin-inline-start | Left |
.mie- | margin-inline-end | Right |
.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.
<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.
-
The last one is wrong.
.mb-4on 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-childreset, which is a rule you now have to write, name and remember. -
Margins collapse; gaps do not. Adjacent block margins merge
into the larger of the two, so
.mb-4above and.mt-2below give you 1rem rather than 1.5rem. That is correct CSS and almost never what anyone intended.gaphas no collapsing rules at all. -
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
gapis set once by whatever is doing the arranging, which is the thing that actually knows. - 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.
<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.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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-*,.clusteror.grid, each of which sets a gap for you. A column of.mb-4is a stack written the long way. -
Not for page rhythm.
.sectionalready applies--space-section, and it is fluid. Replacing it with.py-8pins the band at 2rem on a 27 inch monitor. -
Not for the viewport edge.
.containerapplies--space-gutteron the inline axis. Adding.px-4on 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.