Spacer
Empty space between siblings, for the gaps a parent gap cannot express. Three axis modes, an eight-step ladder plus one-off pixel values, a flex spring with an optional minimum, and a polymorphic render host. Decorative by default.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Spacer } from "@tessinaui/ui";{/* One gap that differs from the rest of the row */}
<div className="flex items-center">
<span>Hello</span>
<Spacer axis="horizontal" size="md" />
<span>World</span>
</div>
{/* A spring: take the leftover room and push the clusters apart */}
<header className="flex items-center gap-2">
<Logo />
<Spacer grow />
<UserMenu />
</header>
{/* A spring that never closes completely */}
<div className="flex items-center">
<span className="truncate">Deployment status</span>
<Spacer grow axis="horizontal" size="lg" />
<span>Ready</span>
</div>When to use
Put gap on the container first. Most of the time that is the whole answer, and it is what the industry has settled on: of eighteen design systems surveyed for this component, fourteen ship no spacer element at all. HeroUI removed theirs in v3 in favour of gap-*, and Mantine's own docs say you usually want spacing props instead. Use Stack's gap, or gap on your own flex or grid parent.
Spacer earns its place for the two things gap cannot express:
- A spring.
growabsorbs whatever room is left and pushes two clusters to opposite ends. A survey of 152 app and web screens found this is how horizontal chrome is built almost everywhere — navigation bars, toolbars, list rows, card footers are allleading cluster · spring · trailing cluster. The same survey found the spring usually needs a floor: when the content nearly fills the bar, the clusters must still keep a gap rather than collide. That isgrowplus asize, and it is the contract SwiftUI documents asSpacer(minLength:). - One exceptional gap, in markup whose parent you cannot give a
gapto, or in an otherwise-even run where a single gap has to differ.
When not to use. Most spacing is not exceptional, and a Spacer is the wrong tool for all of the following:
- Uniform rhythm — every sibling separated by the same amount is
gapon the parent, or Stack. Terser, and no extra DOM node. - A visible line — that is Divider, which carries the right semantics. Do not paint a border on a Spacer.
- Vertical section rhythm in a form or settings page — real products separate sections with a heading, a hairline or a card, never with a bare gap alone.
- Centring a title between two clusters — two springs split the leftover room equally, which centres the title only when the clusters on either side happen to be the same width. They rarely are. Put the row in a grid cell and centre the title against the row instead.
- Even distribution across a row — that is equal-basis children (
flex-1) or a grid, not spacers between them. - A bottom action bar over scrollable content — that bar is sticky or overlaid, with its own padding; nothing pushes it down.
- Safe-area clearance —
env(safe-area-inset-bottom)belongs on the container's padding, not on a spacer between elements.
Proximity is what spacing communicates: keep the smaller steps (xs–sm) for elements that belong together, and the larger ones (xl–3xl) for genuine separation. A gap that is merely bigger does not group anything — a heading or a rule does.
Examples
Default
A fixed horizontal gap between two inline elements. The band is painted here so it is visible; a real Spacer is invisible.
Horizontal sizes
The seven steps from xs to 3xl, applied as width in a row.
Vertical stack
With axis="vertical" the Spacer sets a fixed height between stacked blocks.
Grow
grow absorbs the leftover room and pushes the siblings apart — the navigation-bar pattern. The spring shrinks as far as it has to, so the row survives a 320px phone.
Grow with a minimum
Pass a size alongside grow and it becomes a floor: the gap takes everything going spare, and stops shrinking at the step you named.
Custom size
A number is a one-off in pixels, for the gap the ladder cannot hit. It travels through --spacer-size, so a stylesheet can substitute any CSS length.
Custom element
render swaps the <div> for the element the surrounding markup requires — here an <li>, because a <ul> may only contain list items.
Axis
| Value | CSS applied |
|---|---|
"horizontal" | width only (w-*) — Spacer becomes inline-block |
"vertical" | height only (h-*) — Spacer stays block |
"both" (default) | size-* — square on both axes |
both is the default because a bare <Spacer /> then provides its gap whichever way the parent happens to flow. When the direction is known, naming it is the cheaper box.
Size
| Value | Tailwind | px |
|---|---|---|
"none" | — | 0 |
"xs" | w-1 / h-1 / size-1 | 4 |
"sm" | w-2 / h-2 / size-2 | 8 |
"md" (default) | w-4 / h-4 / size-4 | 16 |
"lg" | w-6 / h-6 / size-6 | 24 |
"xl" | w-8 / h-8 / size-8 | 32 |
"2xl" | w-12 / h-12 / size-12 | 48 |
"3xl" | w-16 / h-16 / size-16 | 64 |
The ladder is the house 4pt grid, and it is the same one Stack and Flex spell gap. It matches PatternFly's spacer scale step for step and name for name, and maps 1:1 onto Tailwind's own 1/2/4/6/8/12/16. Nothing in the screen survey argued for another step; the 12px rung several systems carry between sm and md is available as size={12}.
Custom size
<Spacer axis="horizontal" size={20} />A number is pixels. It is carried by the --spacer-size custom property rather than an inline width, so it can be replaced by any CSS length — including one the prop cannot express:
<Spacer
axis="vertical"
size={16}
style={{ "--spacer-size": "env(safe-area-inset-bottom)" }}
/>Negative and non-finite values clamp to 0.
Grow
<Spacer grow />grow makes the Spacer a spring: flex-grow: 1, flex-shrink: 1, flex-basis: 0. It takes the room its siblings do not want, in a row or in a column, and gives it back when they need it — a spring that cannot shrink is how a toolbar overhangs a narrow phone.
A bare <Spacer grow /> has no size of its own. Pass a size and it becomes a minimum:
{/* At least 24px between the clusters, more when there is room */}
<Spacer grow axis="horizontal" size="lg" />Before this release, <Spacer grow /> also picked up the default md size and
rendered a 16px square that grew. If you were relying on that cross-axis size,
pass it explicitly.
Polymorphic render
Replace the <div> with the element the surrounding markup requires.
<ul role="list" className="flex items-center gap-2">
<li role="listitem">Files</li>
<Spacer grow render={<li />} />
<li role="listitem">Upload</li>
</ul>The explicit role="list" / role="listitem" are load-bearing, not belt-and-braces. A <ul> laid out as flex or grid carries list-style: none, and WebKit then drops the list semantics: VoiceOver reads the items as plain "selectable text" rather than list items, and axe passes the markup either way. Restating the roles is the only thing that keeps the list a list in Safari.
The render element's own props win over the props passed to Spacer, and a ref on it is kept — it is merged with Spacer's, not replaced. That includes aria-hidden: render={<li aria-hidden={false} />} puts the element back in the accessibility tree.
For a visible rule, use Divider rather than a bordered Spacer.
Accessibility
Spacer is decorative: it holds space and says nothing. It renders aria-hidden="true" so assistive technology skips it, and it is never focusable.
Pass aria-hidden={false} when the element is doing structural work and needs to stay in the tree — a Spacer rendered as an <li> in a list whose item count is announced, for example. It works on both the <div> path and the render path.
<Spacer grow render={<li aria-hidden={false} />} />A focusable render host is never hidden. aria-hidden on an element the keyboard can still reach is its own accessibility failure — the node vanishes from the accessibility tree while focus can still land on it. So a host that takes focus on its own (<button>, <a href>, <input>, <select>, <textarea>, <summary>, or anything with tabIndex={0}) does not get the decorative default. An explicit aria-hidden still wins if you really mean it.
Inside a list, use render. A <ul> or <ol> may contain only <li> elements; a bare <div> between items fails the check whether or not it is aria-hidden. Give the Spacer the right host — render={<li />} — or drop it and put gap on the list. Do not reach for role="presentation" on the list item: that removes the item from the list as far as assistive technology is concerned, which is a different failure.
Spacer adds no role and no name. Space alone conveys nothing to a screen-reader user, so if a gap in your layout is carrying meaning — separating groups of content — express that meaning with a heading, a list, or a Divider that is not decorative.
Assistive technology matrix
| AT | Status |
|---|---|
| VoiceOver (macOS) | Passed (2026-09-10, Safari 26 / WebKit) — the Spacer is never announced and is never a stop on the cursor; the render={<li />} spring is aria-hidden, so it is dropped from the tree rather than counted as an item. The list example needed a fix to get there: with implicit list semantics only, VoiceOver announced each row as "selectable text". With role="list" / role="listitem" restated it announces "selectable list item". A/B transcript in the variant ledger |
| NVDA | Untested — not runnable on the maintainers' machines |
| JAWS | Untested — not runnable on the maintainers' machines |
RTL
Spacer has no start or end: it sets physical width and height, both of which are direction-neutral, and it takes no dir prop for that reason. In a flex row the spring follows the parent's own direction, so a grow Spacer that pushes a cluster to the right in LTR pushes it to the left in RTL with no change on your side.
API Reference
Spacer props
| Prop | Type | Default | Description |
|---|---|---|---|
axis | "horizontal" | "vertical" | "both" | "both" | Which dimension the space occupies |
size | "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | number | "md", or "none" under grow | How much space. A number is pixels. Under grow, a minimum rather than a fixed dimension |
grow | boolean | false | Absorb the leftover room on the main axis |
render | ReactElement | — | Polymorphic host element. Its own props and ref win |
aria-hidden | boolean | true | Decorative by default; pass false to put it back in the tree |
className | string | — | Merged with the variant classes; yours wins a conflict |
style | CSSProperties | — | Spread after --spacer-size, so it can override it |
Exports
| Export | Type | Description |
|---|---|---|
Spacer | component | The spacer itself |
spacerVariants | cva | The variant classes, for composing the same box elsewhere |
SpacerAxis | type | "horizontal" | "vertical" | "both" |
SpacerSize | type | The named steps, "none" through "3xl" |
SpacerProps | type | The component's props |
Data attributes
Stable hooks for styling, testing and automation.
| Attribute | On | Value |
|---|---|---|
data-slot="spacer" | root | — (a caller's own data-slot overrides it) |
CSS custom properties
| Property | Default | Effect |
|---|---|---|
--spacer-size | set from size when it is a number | The one-off dimension, as any CSS length |
Notes
- No margin. A Spacer takes its own room in the layout; it does not push its siblings with margin.
- It adds to
gap, it does not replace it. Inside a flex parent that already has agap, the space between the two siblings isgap + spacer. Drop thegap, or usesize="none"withgrow, if that is not what you meant. - SSR-safe. No effects, no measurement; the only hook is the ref merge on the
renderpath.