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.
pnpm add @tessinaui/uiWhen 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.
- One axis with a gap. A column of form fields, a row of actions, a cluster
of chips: set
gapand drop the margins on the children. - A different axis on phones.
directionandgaptake a breakpoint object, so a column on the phone becomes a row on the desktop. - Prefer whitespace to a rule. Use
divideronly when whitespace cannot do the job. Agap="lg"between two groups usually reads better than a rule between six rows. - A rule between repeated rows.
dividerdraws one between each child and works out its own orientation. When a row is anchored by an avatar or icon, inset the rule so it starts at the row's primary text with--stack-divider-inset(see Dividers). - A group that means something. Use
renderto make the host a<nav>or<ul>, rather than leaving a<div>and adding ARIA to it.
When not to use
- Two axes. Use Grid.
- The fuller flexbox surface. A separate
columnGap,wrap-reverseor per-child sizing: use Flex withFlexItem. - Constraining page width. That is Container, not arranging children.
- Pushing one item to the far edge. Put
Spacer with
growbetween two groups. - A rule that stands on its own. Use Divider rather than one repeated between children.
- Interactive behaviour. Stack adds no role or keyboard behaviour; use the component built for the interaction, not a styled stack.
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>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
On color
Inside a coloured Surface, the divider hairline draws in the plate's own ink instead of the page's grey. Stack reads the tone from the Surface, so there is nothing to pass. A custom divider node resolves its own tone.
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.
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.
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 |