Stack
The spacing-first one-dimensional arranger. An axis, a gap, and an optional rule between children — with direction and gap that can change by breakpoint, so a column on the phone becomes a row on the desktop. Ships with HStack and VStack convenience wrappers.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Stack, HStack, VStack } from "@tessinaui/ui";{/* A column, the default */}
<Stack gap="md">
<div>First</div>
<div>Second</div>
</Stack>
{/* A column on the phone, a row from md up */}
<Stack direction={{ base: "vertical", md: "horizontal" }} gap={{ base: "sm", md: "md" }}>
<Button variant="ghost">Cancel</Button>
<Button variant="primary">Save</Button>
</Stack>
{/* A rule between each child, oriented automatically */}
<Stack gap="sm" divider>
<span>Profile</span>
<span>Security</span>
</Stack>When to use
Stack arranges children along one axis and controls the space between them. That is the whole job: an axis, a gap, and optionally a rule.
| Use | When |
|---|---|
Stack | One axis. You want a gap, maybe a divider, maybe a different axis on phones. |
Flex | You need the full flexbox surface — independent rowGap/columnGap, align-content, wrap-reverse — or per-child sizing via FlexItem. |
Grid | Two axes. |
Container | Constraining page width, not arranging children. |
Spacer | Pushing one item to the far edge: <Spacer grow /> between two groups. |
Divider | A rule that stands on its own, rather than one repeated between children. |
Two rules worth taking from the platforms rather than re-deriving:
- Prefer whitespace to a rule. Material's guidance is that dividers are for
when whitespace cannot do the job, and both it and the app screens surveyed
for this component separate groups far more often than they rule every item.
A
gap="lg"between two groups usually reads better than a rule between six rows. - When a row is anchored by an avatar or icon, inset the rule so it starts
at the row's primary text. Apple encodes this in API (
separatorInset,listRowSeparatorLeading) and Material calls it the inset divider; here it is--stack-divider-inset. See Dividers.
Examples
Default
A column with a medium gap.
Direction
The axis, fixed.
Responsive
direction and gap each take a breakpoint object. Note which way round it
goes: on phones a two-action bar is almost always a vertical stack of
full-width buttons, and the row is the desktop case.
Gap
The seven ladder steps.
Alignment
align on the cross axis, justify on the main one.
Wrap
A wrapping cluster, with rowGap opening the between-rows rhythm wider than the
space between neighbouring chips.
Dividers
divider works out its own orientation. Pass a node when you want tones,
dashes or a label — and note that a one-off rule needs no prop at all.
Media object
The most common horizontal composition there is, and where min-w-0,
--stack-divider-inset and a two-tier gap rhythm all earn their keep.
Reverse
Visual order only — the DOM order does not move.
Inline
A stack that sits in the text flow.
Polymorphic render
A link row should be a <nav>; a list of items should be a <ul>.
RTL
HStack and VStack
Direction
direction is "horizontal" or "vertical", defaulting to "vertical".
It also accepts an object keyed by breakpoint:
<Stack direction={{ base: "vertical", md: "horizontal" }} />The keys are base, sm, md, lg and xl — the same names as Tailwind's
own variant prefixes, so there is no separate breakpoint table to learn.
Three behaviours are worth knowing:
- Sparse objects forward-fill.
{ base: "vertical", lg: "horizontal" }is a column at every width belowlgand a row fromlgup. You only declare the bands where something changes. - A band below the first declared entry uses the prop's default. With
{ md: "horizontal" }, everything undermdis a column — not a row, which is what an unsetflex-directionwould otherwise mean. - Values that agree everywhere collapse.
{ base: "horizontal" }emits the single classflex-row, not five banded ones.
Under the hood a responsive value resolves into five fixed, mutually disjoint
bands (max-sm: · sm:max-md: · md:max-lg: · lg:max-xl: · xl:) rather
than a bare utility plus a run of min-width variants. No viewport matches two
bands, so stylesheet emission order cannot decide which one wins — the failure
that once had a container measuring 24px where xl:px-12 asked for 48.
reverse flips the visual order. It is a single boolean across every band, and
it changes paint order only: the DOM, the tab order and the screen-reader
order are unchanged. Use it where those two orders are genuinely allowed to
differ, and not otherwise.
Gap
gap is the space between children: none, xs, sm, md, lg, xl,
2xl — 0, 4, 8, 16, 24, 32 and 48px, the same ladder Flex and Grid use. The
default is md.
It takes the same breakpoint object as direction, with the same forward-fill
and collapse behaviour:
<Stack gap={{ base: "sm", lg: "xl" }} />The resolved value is published as --stack-gap, so a one-off adjustment does
not need a wrapper:
<Stack style={{ "--stack-gap": "13px" }} />Gaps come in tiers, not one flat value. Across the settings and form screens
surveyed, the space between a label and its field ran at roughly a third of the
space between one field group and the next — commonly xs/sm inside a group
against md/lg between them, and some blocks use a third, tighter tier again.
Nesting is how you express that: an outer Stack at the group rhythm, inner ones
at the tighter one. Do not reach for a single gap and split the difference.
rowGap
When a horizontal stack wraps, rowGap sets the space between rows,
independently of the space between neighbouring items:
<Stack direction="horizontal" wrap gap="sm" rowGap="md" />It requires wrap. Without it the prop is ignored rather than applied, because
on an unwrapped column the cross-axis gap is the main gap and setting it here
would silently fight gap.
Align and justify
align runs on the cross axis: start, center, end, stretch (the
default), baseline. justify runs on the main axis: start (the default),
center, end, between, around, evenly.
baseline is worth singling out. A section header — a title on the left, a
"See all" action on the right — reads correctly only when the two sides sit on a
shared baseline rather than being box-centred, and that pattern was near-
universal in the screens surveyed.
Both props are scalar. If you need alignment to change by breakpoint, use
className with Tailwind's own variants.
Wrap and inline
wrap turns on flex-wrap; pair it with rowGap above. A wrapped cluster's
last row packs to the start — it is never justified.
inline swaps display: flex for inline-flex, so the stack sits in the text
flow instead of taking a line of its own.
Dividers
divider puts a rule between each child — never before the first or after the
last.
<Stack divider>…</Stack> {/* the house hairline */}
<Stack divider={<Divider tone="subtle" />}>…</Stack> {/* anything richer */}divider derives its own orientation from the axis: a row gets a vertical
rule, a column a horizontal one, and a responsive stack gets the right one in
each band. There is no orientation prop to pass, and therefore none to forget
— a mistake other libraries leave available and catch nothing on.
The auto rule carries two classes that matter more than they look:
shrink-0— a 1px flex child in a scrolling column is crushed to exactlyheight: 0once content overflows. It renders perfectly in every short demo and vanishes in the real list, which is how the bug survives review.self-stretch— without it,align="center"collapses the rule to zero length.
A custom node must carry both itself. <Divider /> already does.
Inset
Set --stack-divider-inset on a column to pull the rule's start edge in, so it
begins at the row's primary text rather than at the frame:
<Stack divider style={{ "--stack-divider-inset": "3rem" }}>Set it to the leading slot's width plus the row gap. It uses a logical margin,
so it stays on the correct side under dir="rtl". The inset applies to a
scalar-vertical stack; in a responsive stack the same margin would push the
rule sideways in the row bands, so pass <Divider inset="start" /> as the node
there instead.
One-off rules
The divider prop is for the repeating case. A single rule — the line above a
checkout total, say — is just an ordinary child:
<Stack gap="sm">
<Row label="Subtotal" />
<Row label="Delivery" />
<Divider tone="strong" />
<Row label="Total" strong />
</Stack>Polymorphic render
render swaps the host element while keeping the layout:
<Stack render={<nav aria-label="Account" />} direction="horizontal" gap="md" />
<Stack render={<ul />} gap="sm" divider>…</Stack>Props merge rather than replace, and event handlers compose: an onClick on
Stack and one on the host element both fire.
The <ul> case works because the interleaved rule is not wrapped in a
container element — it is a direct, explicitly presentational child, so the list
still contains only list items and presentational nodes. A wrapper <div> there
would fail an accessibility audit with list (serious), and a unit test
asserting the tag name would pass straight through it, because a tag name is not
a role.
HStack and VStack
HStack and VStack are Stack with the axis locked. They take every other
Stack prop, and they reject direction — including the silent case where a
forwarding wrapper spreads direction: undefined onto them.
They are scalar by definition. A stack whose axis changes by breakpoint is
Stack with a direction object.
RTL
A horizontal stack lays out along the inline axis, so it mirrors under
dir="rtl" with no extra work: the first child sits on the right, and the gap
is logical. The auto divider's inset uses ms-* rather than a physical margin,
so an inset rule stays on the correct side in both directions.
dir is stamped on the element only when you pass it; otherwise direction is
inherited.
Accessibility
Stack is a layout primitive. It adds no role, no label and no keyboard
behaviour, and it does not interfere with the semantics of its children — which
is why render exists: reach for <nav>, <ul> or <section> when the group
means something, rather than leaving a <div> and adding ARIA to it.
- Interleaved dividers are decorative. They are hidden from assistive
technology and marked presentational, so a screen reader announces the content
and not the furniture. For a rule that carries meaning, pass
<Divider decorative={false} />. reversedoes not reorder the DOM. Anyone using a keyboard or a screen reader follows the source order, not the painted one. Only use it where the two are allowed to differ.- Contrast is a property of what you put inside a Stack; the primitive
paints nothing but the optional 1px rule, which uses the house
--bordertoken. - Touch-target sizing applies to the interactive children, not to the container.
Assistive technology matrix
| AT | Status |
|---|---|
| VoiceOver (macOS) | Passed (2026-09-10, Safari) — walking stack-render, the <nav> host announces as "Account navigation" and its links read individually; the <ul> host reads "Two-factor authentication / Recovery codes / Active sessions" consecutively. No divider is announced anywhere — the interleaved rules are silent, which is the behaviour the decorative markup is there to produce. Transcript in the variant ledger |
| NVDA | Untested — not runnable on the maintainers' machines |
| JAWS | Untested — not runnable on the maintainers' machines |
API Reference
Stack Props
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "horizontal" | "vertical" | { base?, sm?, md?, lg?, xl? } | "vertical" | Layout axis; the object form varies it by breakpoint |
reverse | boolean | false | Reverse the visual order. Paint order only |
gap | StackGap | { base?, sm?, md?, lg?, xl? } | "md" | Space between children |
rowGap | StackGap | — | Space between wrapped rows. Requires wrap |
align | "start" | "center" | "end" | "stretch" | "baseline" | "stretch" | Cross-axis alignment |
justify | "start" | "center" | "end" | "between" | "around" | "evenly" | "start" | Main-axis distribution |
wrap | boolean | false | Allow children to wrap |
inline | boolean | false | Render as inline-flex |
divider | boolean | ReactNode | — | true for a self-orienting house hairline, or any node |
render | useRender.RenderProp | <div /> | Render as a different host element |
dir | "ltr" | "rtl" | — | Text direction. Stamped only when set |
className | string | — | Extra classes on the root element |
StackGap is "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl".
The component extends all standard div HTML attributes.
HStack / VStack
Identical to Stack with direction locked to "horizontal" and
"vertical" respectively, and removed from the prop type.
Exports
| Export | Type | Description |
|---|---|---|
Stack | component | The arranger |
HStack / VStack | component | Axis-locked shorthands |
stackVariants | cva | The non-responsive variants, for composing a stack elsewhere |
StackProps | type | |
StackDirection, StackGap, StackAlign, StackJustify, StackDir | type | Value unions |
StackBreakpoint | type | "base" | "sm" | "md" | "lg" | "xl" |
ResponsiveStackDirection, ResponsiveStackGap | type | The scalar-or-object prop types |
Data attributes
Stable hooks for styling, testing and automation.
| Attribute | On | Value |
|---|---|---|
data-slot="stack" | root | — |
data-orientation | root | horizontal | vertical — the base band's value on a responsive stack |
data-slot="stack-divider" | each auto rule | — |
CSS custom properties
| Property | Default | Effect |
|---|---|---|
--stack-gap | the gap step | Space between children, when gap is scalar |
--stack-gap-base … --stack-gap-xl | the per-band step | Set instead of --stack-gap when gap is responsive |
--stack-row-gap | the rowGap step | Space between wrapped rows |
--stack-divider-inset | 0px | How far the auto rule's start edge is pulled in, on a column |
Notes
- A lone Fragment is seen through:
<Stack divider>{rows}</Stack>whererowsis<>{a}{b}</>still draws a rule betweenaandb. A Fragment with siblings stays one deliberate group, and the rule goes around it. - Falsy children are dropped before rules are counted, so a conditionally rendered child cannot leave a rule with nothing on one side of it.
- With
justify="between"and adivider, the rules are flex items too and receive their share of the distributed space.