Chapter 4
Your first layout primitive: Stack
The Stack is the most frequently used layout primitive you'll build.
The Stack is the most frequently used layout primitive you’ll build. It solves a problem so fundamental that most developers don’t realize they’re working around it every day: how to apply consistent vertical spacing between elements without creating orphaned margins.
The problem
Consider a typical approach to vertical spacing:
p {
margin-bottom: 1.5rem;
}
This creates a margin below every paragraph. When a paragraph is followed by another element, the spacing looks right. But the last paragraph in a container gets a margin that combines with the container’s padding, producing double the intended space at the bottom. The margin exists regardless of context, because the style belongs to the element rather than to the relationship between elements.
You end up writing :last-child overrides, or using a utility class to remove the bottom margin, or wrapping elements in containers that cancel out the extra space. These are all workarounds for the same root problem: styling elements individually when spacing is a property of adjacency.
The solution
The Stack primitive styles the context rather than the individual elements. It applies margin only between adjacent siblings, using a pattern known as the owl selector:
.rudi-stack > * + * {
margin-block-start: var(--stack-space);
}
The * + * selector targets any element that is immediately preceded by another element. The > child combinator scopes it to direct children only. The result: spacing appears between elements, but never before the first child or after the last child. No :last-child overrides needed.
Building the component
Create src/layouts/Stack/Stack.tsx:
// src/layouts/Stack/Stack.tsx
import { Children, cloneElement, forwardRef, isValidElement } from 'react'
import { cn } from '@/utils/cn'
import './stack.css'
export interface RudiStackProps extends React.HTMLAttributes<HTMLElement> {
/** CSS spacing value. Defaults to --rudi-layout-stack-space-default token. */
space?: string
/** Apply spacing recursively to all nested elements, not just direct children. */
recursive?: boolean
/** 1-based index of the child after which to insert an auto margin, splitting the stack. */
splitAfter?: number
/** HTML element to render. */
as?: React.ElementType
children?: React.ReactNode
}
export const RudiStack = forwardRef<HTMLElement, RudiStackProps>(function RudiStack(
{
space,
recursive = false,
splitAfter,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (space) customProperties['--stack-space'] = space
// Apply margin-block-end: auto to the splitAfter child via React.
// CSS :nth-child() does not accept custom properties, so this must
// be handled in the component rather than in the stylesheet.
// Children.toArray filters null/false values so conditional children
// do not shift the numeric index.
const childArray = Children.toArray(children)
const styledChildren = splitAfter
? childArray.map((child, index) => {
if (index === splitAfter - 1 && isValidElement(child)) {
return cloneElement(
child as React.ReactElement<{ style?: React.CSSProperties }>,
{
style: {
...(child.props as { style?: React.CSSProperties }).style,
marginBlockEnd: 'auto',
},
},
)
}
return child
})
: children
return (
<Element
ref={ref}
className={cn(
'rudi-stack',
recursive && 'rudi-stack--recursive',
splitAfter && 'rudi-stack--split',
className,
)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{styledChildren}
</Element>
)
})
Four implementation details are worth understanding:
The component doesn’t import or use React Aria. Layout primitives have no interactive behavior, so they don’t need accessibility hooks. They’re pure CSS layout wrapped in a React component for composability and token integration.
The space prop is optional. When omitted, no inline --stack-space property is set. The CSS class sets --stack-space to var(--rudi-layout-stack-space-default, 1.5rem), so the token provides the value. When provided, the inline property overrides the class-level value. This pattern gives you token-driven defaults with per-instance overrides, and the CSS does all the work.
The as prop lets the consumer render a semantic element. <RudiStack as="ul"> renders a <ul> instead of a <div>, which matters for accessibility when the Stack’s children are list items.
The component is wrapped with forwardRef, exposing the underlying DOM node to consumers via a ref. This is necessary when the Stack is used as a measurement target: scroll containers, IntersectionObserver roots, or elements that need programmatic focus. The ref is typed as HTMLElement rather than a more specific type because the as prop can render any element. This is a deliberate trade-off: stricter typing (where the ref type matches the rendered element) requires a polymorphic ref pattern that TypeScript doesn’t support natively without a manual generic wrapper function. For most use cases, HTMLElement is sufficient.
The CSS
Create src/layouts/Stack/stack.css, next to the component:
/* Stack */
.rudi-stack {
--stack-space: var(--rudi-layout-stack-space-default, 1.5rem);
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.rudi-stack > * {
margin-block: 0;
}
.rudi-stack > * + * {
margin-block-start: var(--stack-space);
}
/* Recursive: apply spacing at any nesting depth */
.rudi-stack--recursive * + * {
margin-block-start: var(--stack-space);
}
/* Split: push elements after the nth child to the bottom */
.rudi-stack--split {
min-block-size: 100%;
}
Note: the splitAfter behavior is applied via React, not pure CSS. CSS :nth-child() accepts selector math, not custom properties, so :nth-child(var(--n)) is invalid. Instead, the Stack component applies an inline margin-block-end: auto style to the correct child element.
Notice what you are not doing: adding an @import to a global stylesheet. Stack.tsx already imports ./stack.css directly, and that side-effect import is the only thing connecting the two files.
That choice is worth a paragraph, because it is the one structural decision in this chapter that is hard to reverse.
A single global layouts.css is simpler to reason about, and it is what most guides do. Colocating the stylesheet with the component, and importing it from the component, buys two things instead. The rules for a component live next to the component, so deleting it deletes its styles and nothing goes stale; and the import makes the dependency explicit rather than implied by load order. It is worth being precise about what it does not buy: the library build in chapter 12 uses a single entry, so every component’s CSS is concatenated into one output file regardless. Per-component delivery would need a multi-entry build, which chapter 12 discusses.
The cost is one extra file per component and no single place to read the whole system’s CSS. That trade is worth making for a package other people install.
The first rule resets vertical margins on all direct children to zero, removing the browser’s default margins. The second rule applies the stack spacing only between adjacent siblings. The reset-then-apply pattern is what eliminates the orphaned margin problem.
When the space prop is set, its inline --stack-space declaration overrides the class rule through the cascade. Otherwise the class reads the --rudi-layout-stack-space-default token, with 1.5rem as a hardcoded fallback. In practice, the token value always exists (Style Dictionary generates it), so the hardcoded fallback is just insurance.
With the component and CSS in place, here’s the base Stack rendered from the companion repository’s Storybook. The Default story shown below is the same one you’ll define later in this chapter.
The recursive prop
By default, Stack only spaces its direct children. The > combinator ensures nested elements aren’t affected. But sometimes you want uniform spacing at every nesting level, for example when rendering Markdown content where you don’t control the nesting structure.
The recursive variant removes the child combinator:
.rudi-stack--recursive * + * {
margin-block-start: var(--stack-space);
}
Use this sparingly. Recursive spacing affects every nested element, which means list items, nested divs, and other structures will all receive the same margin. For most layouts, nesting non-recursive Stacks with different spacing values gives you more precise control.
The splitAfter prop
Making the Stack a flexbox column context gives it one more capability: splitting. By applying margin-block-end: auto to a specific child, you push everything after that child to the bottom of the available space.
This is useful for card-like layouts where you want some content at the top and a button pinned to the bottom:
<RudiStack splitAfter={2} style={{ minHeight: '300px' }}>
<h3 className="text-lg">Card title</h3>
<p>Card description that might vary in length.</p>
<RudiButton>Action</RudiButton> {/* Pushed to the bottom */}
</RudiStack>
The splitAfter prop tells the Stack component which child to apply margin-block-end: auto to via Children.toArray and cloneElement. The Stack needs a defined height (or min-height) for the split to produce a visible gap. Without a height constraint, the flexbox column collapses to its content height and the auto margin has no space to distribute.
The public export
Add the component to the main entry point in src/index.ts, exporting from the module directly:
import './styles.css'
export { cn } from './utils/cn'
export { RudiStack } from './layouts/Stack/Stack'
export type { RudiStackProps } from './layouts/Stack/Stack'
Two conventions are established here, and both hold for every component in the rest of the guide.
Every exported name carries the Rudi prefix. The internal function is RudiStack, the interface is RudiStackProps, and the file is still Stack.tsx. A component library exports into a namespace it does not control: applications have their own Stack, Text, Card, and Switch, and a bare Stack forces every consumer to write import { Stack as UIStack }. Prefixing costs four characters at each call site and removes the collision entirely. Pick your own prefix; just pick one before you have 50 components.
There are no per-component index.ts barrel files. src/index.ts reaches straight into ./layouts/Stack/Stack. A barrel per component adds a second layer of re-exports that does nothing here, because src/index.ts is already the package’s one entry point. One entry point is enough.
Writing the stories
Create src/layouts/Stack/Stack.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react-vite'
import { RudiStack } from './Stack'
const meta = {
title: 'Layouts/Stack',
component: RudiStack,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Arranges children in a vertical stack with consistent spacing between each item. Supports recursive spacing into nested elements and split positioning.',
},
},
},
args: {
recursive: false,
},
argTypes: {
as: {
control: 'text',
description: "The HTML element to render (e.g., 'div', 'ul', 'ol')",
table: { category: 'Content' },
},
children: {
description: 'Content to display inside the stack',
table: { category: 'Content' },
},
recursive: {
control: 'boolean',
description:
'When enabled, applies spacing to all nested elements, not just direct children',
table: { category: 'Appearance' },
},
space: {
control: 'text',
description:
"Vertical gap between items using any CSS length value (e.g., '1.5rem')",
table: { category: 'Layout' },
},
splitAfter: {
control: 'number',
description:
'Pushes all items after this index to the bottom of the stack (1-based)',
table: { category: 'Layout' },
},
},
} satisfies Meta<typeof RudiStack>
export default meta
type Story = StoryObj<typeof meta>
function Placeholder({ label }: { label: string }) {
return (
<div
style={{
border: '1px solid var(--rudi-color-border-default)',
borderRadius: 'var(--rudi-radius-md)',
padding: 'var(--rudi-spacing-4)',
background: 'var(--rudi-color-background-surface-raised)',
}}
>
{label}
</div>
)
}
export const Default: Story = {
render: (args) => (
<RudiStack {...args}>
<Placeholder label="First child" />
<Placeholder label="Second child" />
<Placeholder label="Third child" />
</RudiStack>
),
}
export const Dense: Story = {
args: { space: '0.5rem' },
render: (args) => (
<RudiStack {...args}>
<Placeholder label="First" />
<Placeholder label="Second" />
<Placeholder label="Third" />
</RudiStack>
),
}
export const Loose: Story = {
args: { space: '3rem' },
render: (args) => (
<RudiStack {...args}>
<Placeholder label="First" />
<Placeholder label="Second" />
<Placeholder label="Third" />
</RudiStack>
),
}
export const SplitAfterSecond: Story = {
args: { splitAfter: 2 },
decorators: [
(Story) => (
<div style={{ height: '400px', border: '1px dashed #ccc' }}>
<Story />
</div>
),
],
render: (args) => (
<RudiStack {...args} style={{ height: '100%' }}>
<Placeholder label="Top item 1" />
<Placeholder label="Top item 2" />
<Placeholder label="Pushed to bottom" />
</RudiStack>
),
}
export const AsUnorderedList: Story = {
render: () => (
<RudiStack as="ul" space="0.75rem" role="list">
<li>First item</li>
<li>Second item</li>
<li>Third item</li>
</RudiStack>
),
}
Each story demonstrates a specific feature. Default shows the token-driven spacing. Dense and Loose show prop overrides. SplitAfterSecond shows the split behavior with a height-constrained container. AsUnorderedList demonstrates the as prop with semantic HTML.
Three details in the meta block are conventions, not decoration, and every story file in the rest of the guide repeats them.
parameters.docs.description.component is the paragraph that appears at the top of the generated documentation page. Without it the page opens with a bare component name and a props table, which tells a reader what the props are but never what the component is for.
Every argTypes entry carries a description. Storybook renders these in the props table, so this is where a consumer finds out that splitAfter is 1-based, or that space takes any CSS length rather than a size keyword. A props table without descriptions is a list of types.
Every argTypes entry also carries table: { category }, which groups the controls panel into something scannable instead of one flat list. Storybook has no parameter that pins an explicit order across categories, so the grouping is what you get; within a category, declaration order in argTypes is respected. The grouping only works if you assign categories consistently, so assign them as you write each story rather than retrofitting later.
Writing the tests
Create src/layouts/Stack/Stack.test.tsx:
import { render, screen } from '@testing-library/react'
import { axe } from 'vitest-axe'
import { describe, it, expect } from 'vitest'
import { RudiStack } from './Stack'
describe('Stack', () => {
it('renders children', () => {
render(
<RudiStack>
<p>First</p>
<p>Second</p>
</RudiStack>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('applies the default class', () => {
const { container } = render(
<RudiStack>
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild).toHaveClass('rudi-stack')
})
it('merges a custom className', () => {
const { container } = render(
<RudiStack className="mt-4">
<p>Child</p>
</RudiStack>,
)
const el = container.firstChild as HTMLElement
expect(el).toHaveClass('rudi-stack')
expect(el).toHaveClass('mt-4')
})
it('sets --stack-space when the space prop is passed', () => {
const { container } = render(
<RudiStack space="2rem">
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild).toHaveStyle('--stack-space: 2rem')
})
it('does not set inline --stack-space when space prop is omitted', () => {
const { container } = render(
<RudiStack>
<p>Child</p>
</RudiStack>,
)
const style = (container.firstChild as HTMLElement).getAttribute('style')
expect(style).toBeNull()
})
it('applies the recursive class when recursive is true', () => {
const { container } = render(
<RudiStack recursive>
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild).toHaveClass('rudi-stack--recursive')
})
it('does not apply the recursive class by default', () => {
const { container } = render(
<RudiStack>
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild).not.toHaveClass('rudi-stack--recursive')
})
it('applies the split class when splitAfter is passed', () => {
const { container } = render(
<RudiStack splitAfter={2}>
<p>A</p>
<p>B</p>
<p>C</p>
</RudiStack>,
)
expect(container.firstChild).toHaveClass('rudi-stack--split')
})
it('applies margin-block-end: auto to the splitAfter child', () => {
const { container } = render(
<RudiStack splitAfter={2}>
<p>A</p>
<p>B</p>
<p>C</p>
</RudiStack>,
)
const secondChild = container.firstChild?.childNodes[1] as HTMLElement
expect(secondChild.style.marginBlockEnd).toBe('auto')
})
it('renders the correct element via the as prop', () => {
const { container } = render(
<RudiStack as="section">
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild?.nodeName).toBe('SECTION')
})
it('renders a div by default', () => {
const { container } = render(
<RudiStack>
<p>Child</p>
</RudiStack>,
)
expect(container.firstChild?.nodeName).toBe('DIV')
})
it('forwards additional HTML attributes', () => {
const { container } = render(
<RudiStack data-testid="my-stack" id="stack-1">
<p>Child</p>
</RudiStack>,
)
const el = container.firstChild as HTMLElement
expect(el).toHaveAttribute('data-testid', 'my-stack')
expect(el).toHaveAttribute('id', 'stack-1')
})
it('has no accessibility violations', async () => {
const { container } = render(
<RudiStack>
<p>First</p>
<p>Second</p>
</RudiStack>,
)
expect(await axe(container)).toHaveNoViolations()
})
})
That last test runs axe-core against the rendered output. It needs import { axe } from 'vitest-axe' at the top of the file, and chapter 11 wires up the matcher that makes toHaveNoViolations() work.
The other tests verify props and behavior rather than rendered CSS, which is the browser’s job. Several of them do assert on class names and custom properties, because those are the contract between the component and its stylesheet. A test that only queried by role would pass while splitAfter silently stopped emitting rudi-stack--split.
What you have now
One working layout primitive, fully documented and tested. The pattern you used here (token-driven CSS custom properties, prop-based overrides, the as prop, class-based modifiers, cn() merging) applies to every layout primitive and every UI component you build from here. The next chapter builds the remaining seven layout primitives using the same pattern.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.