Stack
.stack is a flex column with a gap. That is the whole component — five
declarations — and it is the most useful class in Deck, because it is the reason
almost nothing else needs a margin.
When to use it
Use it whenever two or more things sit one above another and need space between them. That is most of a page.
Export finished
2,481 rows written to invoices-2026-03.csv.
<div class="stack">
<h3>Export finished</h3>
<p>2,481 rows written to invoices-2026-03.csv.</p>
<button class="btn btn-primary">Download</button>
</div>
<div class="stack">
<h3>Export finished</h3>
<p>2,481 rows written to invoices-2026-03.csv.</p>
<button class="btn btn-primary">Download</button>
</div>
Why gap instead of margins
This is the argument the rest of Deck rests on, so it is worth making properly.
Spacing something with margins means writing a rule for every element that might
sit next to every other element. The usual approach is the lobotomised owl —
* + * { margin-block-start: 1rem } — and it works, but it has four
problems that a gap does not.
Margins collapse; gaps do not
Adjacent vertical margins merge into the larger of the two, so a
1rem margin below one element and a 2rem margin above
the next produces 2rem of space, not 3rem. Sometimes
that is what you wanted. The problem is that you cannot tell by reading the
markup, and the answer changes when a parent gains
overflow, display: flow-root or a border.
A gap is the distance between two items. It is that distance in every circumstance.
Margins need exceptions; gaps have none
A margin below every child puts a margin below the last child too, which
is why margin-based systems are full of :last-child { margin-block-end: 0
}. A gap only exists between two items, so the first and last edges are
clean without an exception.
That difference shows up the moment something is conditional. Hide the last element and a margin system leaves a hole; a gap closes up.
First
Second
Third
First
Second
Third
<div class="split">
<div class="card"><div class="card-body"><div class="stack stack-2">
<span class="badge">Three items</span>
<p>First</p><p>Second</p><p>Third</p>
</div></div></div>
<div class="card"><div class="card-body"><div class="stack stack-2">
<span class="badge">Last one hidden</span>
<p>First</p><p>Second</p><p hidden>Third</p>
</div></div></div>
</div>
<div class="split">
<div class="card"><div class="card-body"><div class="stack stack-2">
<span class="badge">Three items</span>
<p>First</p><p>Second</p><p>Third</p>
</div></div></div>
<div class="card"><div class="card-body"><div class="stack stack-2">
<span class="badge">Last one hidden</span>
<p>First</p><p>Second</p><p hidden>Third</p>
</div></div></div>
</div>
The parent owns the spacing, not the child
With margins, a heading carries its spacing wherever it goes — so the same
<h3> is wrong in a card, wrong in a modal, and right only where
the margin was chosen. Moving it means overriding it.
With a gap, the container decides. The same heading is correct in all three places
because it brings no opinion with it. That is why Deck's components almost never
set a margin: .card-body, .field,
.list-main and .alert are all flex columns with a gap,
and every one of them is a .stack with a different default.
A gap is direction-agnostic
margin-block-start is fine, but the moment a stack becomes a row —
or the writing mode changes — the property is wrong and has to be swapped.
gap means "between items" in whatever direction the container is
running, so .stack and .cluster take the same
--gap and neither needs an RTL rule.
The honest counter-argument: gap needs a flex or grid container, so it
cannot space arbitrary elements in normal flow. Deck accepts that and uses a
container almost everywhere. Where you genuinely have flow content you do not
control — rendered Markdown, a CMS body — use .prose, which is the one
place Deck does set margins on descendants.
The spacing scale
Every spacing utility in Deck uses the same seven steps. A step that exists in one
family exists in all of them, so .stack-3, .gap-3,
.p-3 and .mt-3 are all the same distance.
| Class | Token | Value | What it is for |
|---|---|---|---|
.stack-0 |
--space-0 |
0 |
Nothing. Two elements that must touch — a table inside its wrapper. |
.stack-1 |
--space-1 |
.25rem |
Hairline. A label and the control it names, when they read as one thing. |
.stack-2 |
--space-2 |
.5rem |
Tight. A title and its subtitle. |
.stack-3 |
--space-3 |
.75rem |
Related blocks inside a card or a section. |
.stack-4 |
--space-4 |
1rem |
The default. What you get with no step class at all. |
.stack-6 |
--space-6 |
1.5rem |
Loose. Separating parts of a page. |
.stack-8 |
--space-8 |
2rem |
Very loose. Whole sections, when .section is too much. |
<div class="grid grid-tight">
<div class="card"><div class="card-body">
<div class="stack stack-1"><span class="badge">stack-1</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
<div class="card"><div class="card-body">
<div class="stack stack-3"><span class="badge">stack-3</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
<div class="card"><div class="card-body">
<div class="stack stack-6"><span class="badge">stack-6</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
</div>
<div class="grid grid-tight">
<div class="card"><div class="card-body">
<div class="stack stack-1"><span class="badge">stack-1</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
<div class="card"><div class="card-body">
<div class="stack stack-3"><span class="badge">stack-3</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
<div class="card"><div class="card-body">
<div class="stack stack-6"><span class="badge">stack-6</span><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div><div class="skeleton" style="block-size:1.5rem"></div></div>
</div></div>
</div>
Why there is no step 5
The steps are 0, 1, 2, 3, 4, 6, 8. Five is missing on purpose. At 1.25rem it is four pixels from step 4 and four from step 6 — a difference nobody sees in a real layout, and a choice nobody can make on purpose.
A half step between two neighbours is what people reach for when they are not
sure, and reaching for it is how a codebase ends up with four spacings that are
all almost the same. It is also, concretely, how this repository ended up with
.stack-5 in its own demo when no such class existed: the markup
looked right, nothing errored, and the element silently fell back to the default.
The scale is short so that the choice stays deliberate.
The --space-* tokens keep their full range, 0 through 24. Tokens are
the palette a component composes from; the utilities are the smaller set you write
markup with. Those are different jobs, so they are different sizes.
How one class is configurable
.stack declares gap: var(--gap, var(--space-4)), and every
step class sets nothing but --gap. Three consequences follow, and they
are what make the pattern worth copying:
- The step classes are one declaration each, so the whole family costs almost nothing in the stylesheet.
-
A value the scale does not have is
style="--gap:3.5rem", with no new class and no!important. -
--gapis shared with.cluster,.grid,.split,.scrollerand.center— so.stack-6on a.gridworks, because both read the same property. -
A step class on its own does nothing. It sets a property and
nothing reads it, so
class="stack-3"without.stackrenders as an ordinary block with no gap at all — and fails silently, because the class is real. Write both:class="stack stack-3". The same goes for.card-body, which sets a fixed gap of its own that a step class cannot change; nest a.stackinside it instead, as the example below does.
<div class="stack" style="--gap:3.5rem">
<div class="skeleton" style="block-size:1.5rem"></div>
<div class="skeleton" style="block-size:1.5rem"></div>
</div>
<div class="stack" style="--gap:3.5rem">
<div class="skeleton" style="block-size:1.5rem"></div>
<div class="skeleton" style="block-size:1.5rem"></div>
</div>
Because the step classes only set a custom property, .stack-2 on a
.cluster does exactly what .cluster-tight does. That is
convenient and slightly untidy — see
cluster, where the two naming schemes meet.
Centred columns
.center is .stack with
align-items: center and text-align: center. It reads the
same --gap, so the step classes work on it unchanged.
No projects yet
Create one to get started.
<div class="center stack-3">
<span class="avatar avatar-lg">SR</span>
<h3>No projects yet</h3>
<p class="text-muted">Create one to get started.</p>
<button class="btn btn-primary">New project</button>
</div>
<div class="center stack-3">
<span class="avatar avatar-lg">SR</span>
<h3>No projects yet</h3>
<p class="text-muted">Create one to get started.</p>
<button class="btn btn-primary">New project</button>
</div>
align-items: center also stops children from stretching to the full
width, which is why the button above is its natural size rather than full width. In
a plain .stack it would stretch.
Classes
Generated from src/04-layout.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.stack |
deck.layout |
Vertical rhythm without margin collapse surprises | 04-layout.css:51 |
.stack-0 |
deck.layout |
No gap. For two elements that must touch. | 04-layout.css:57 |
.stack-1 |
deck.layout |
A hairline. A label and the control it names, when the two read as one thing. | 04-layout.css:60 |
.stack-2 |
deck.layout |
Tight vertical rhythm, for a label and its control or a title and its subtitle. | 04-layout.css:63 |
.stack-3 |
deck.layout |
The default rhythm for related blocks inside a card or a section. | 04-layout.css:65 |
.stack-4 |
deck.layout |
The default, named. Same as .stack with no step class. | 04-layout.css:67 |
.stack-6 |
deck.layout |
Loose vertical rhythm, for separating whole sections of a page. | 04-layout.css:69 |
.stack-8 |
deck.layout |
Very loose, for whole sections of a page where .section is too much. | 04-layout.css:71 |
.center |
deck.layout |
Center a thing with a max width and no side effects | 04-layout.css:150 |
Tokens
The seven steps the utilities use, plus the two computed spacings that the page-level classes read.
| Token | Live | Declared value |
|---|---|---|
--space-0 |
0 |
|
--space-1 |
.25rem |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--space-6 |
1.5rem |
|
--space-8 |
2rem |
|
--space-section |
clamp(2.5rem, 1.4rem + 5.5vw, 6rem) |
|
--space-gutter |
clamp(1rem, .55rem + 2.2vw, 2rem) |
Accessibility
-
A stack has no semantics. It is a
<div>withdisplay: flexand no role. Put it on a<ul>,<section>or<form>when the grouping means something; the styles do not care what element they are on. -
Flex changes nothing about reading order as long as you do not
reorder.
flex-direction: columnkeeps DOM order, so what a screen reader hears matches what a sighted reader sees. -
Never use
orderorrow-reverseto fix a layout. Both leave the DOM order — which is the tab order and the reading order — pointing one way while the visual order points the other. If the order is wrong, the markup is wrong. -
.stackon a<ul>can silence the list. Some Safari and VoiceOver combinations drop list semantics whendisplay: flexis applied. Addrole="list"back if the item count matters. -
Spacing is not a separator. A large gap groups things visually
and says nothing to a screen reader. If two groups are genuinely distinct, use a
heading or a
<section>, not.stack-8. -
Gap does not scale with text size unless the token does. Deck's
steps are in
rem, so they grow with the reader's root font size — a stack set in pixels would not, and a page zoomed to 200% would have text touching.
Right to left
Nothing to do. gap is not a directional property, and a column stack has
no start or end edge to mirror. This is one of the clearest cases for gap over
margin: margin-block-start would need no RTL rule either, but
margin-left on a row would, and a gap never gives you the chance to
write the wrong one.
Reduced motion
Nothing about a stack animates or transitions, so
prefers-reduced-motion changes nothing here.
Printing
src/99-print.css has no rule for .stack, which is
deliberate — a flex column with a gap prints correctly as it is. The layout classes
that are unwound for print are .grid and
.split, which become display: block so that content can
break across pages instead of being trapped in a fixed track.
Overriding it
/* One stack, no layer needed */
<div class="stack" style="--gap:3.5rem">
@layer app.components {
/* A step the scale does not have, if you need it repeatedly */
.stack-12 { --gap: var(--space-12); }
/* A different default for every stack on the site */
.stack { gap: var(--gap, var(--space-3)); }
}
Prefer setting --gap over setting gap: the custom property
is what every step class writes to, so a --gap override composes with
them and a flat gap override silently beats them.
When not to use it
-
Not for a row. Use
.cluster, which wraps, or.bar, which does not. A.stackwithflex-direction: rowoverridden is a cluster you have to maintain. -
Not for content you did not write. Rendered Markdown, a CMS body
or a WYSIWYG field arrives as flow content with its own margins. Use
.prose, which sets the vertical rhythm on descendants — the one place Deck accepts margins, because there is no container to put a gap on. -
Not for a page's outer rhythm. Use
.section, whose padding is aclamp()that grows with the viewport. A fixed.stack-8between page sections is too much on a phone and too little on a monitor. -
Not when the items should share a row when there is room. That is
.grid, which reflows on its own width. A stack is always one column. -
Not to fake a separator. If the reader needs to know two groups
are different, give them a heading or an
<hr>. Extra space is invisible to anyone not looking at the screen.