Chapter 5
The rest of the layout system
You have the pattern down from the Stack chapter. This chapter applies that same pattern seven more times.
You have the pattern down from the Stack chapter: a React component that renders a semantic element with a CSS class and optional inline custom property overrides. The CSS does the layout work, tokens provide the defaults, and props allow per-instance control. This chapter applies that same pattern seven more times.
Each section covers the primitive’s CSS, TypeScript interface, and a brief explanation of the layout technique. Stories follow the same conventions established for Stack, and the full story files are in the companion repository. This chapter focuses on the CSS techniques that make each primitive work.
One convention carries through every prop in this chapter: spacing props take a concrete CSS length, not a size keyword. There is no space="md". Pass space="1.5rem", or better, pass a token reference like space="var(--rudi-spacing-6)" so the value still moves when the theme does.
Box
The Box is the simplest layout primitive. It applies consistent padding and an optional border. You might reasonably ask why this needs a component at all, when a consumer could write padding: 1rem or a utility class and be done. The answer is consistency: a Box guarantees that padding comes from the token system, so when the default container padding moves from 1rem to 1.25rem, every Box in the application moves with it. A hand-written 1rem does not.
.rudi-box {
--box-padding: var(--rudi-layout-box-padding, 1rem);
padding: var(--box-padding);
}
.rudi-box--bordered {
border: 1px solid var(--rudi-layout-box-border-color);
border-radius: var(--rudi-layout-box-border-radius);
}
.rudi-box--invert {
background-color: var(--rudi-layout-box-invert-bg);
color: var(--rudi-layout-box-invert-text);
}
Every value here is a layout.box.* token from tokens/components.json, and each one aliases a semantic token: border-color points at {color.border.default}, invert-bg at {color.background.inverted}, invert-text at {color.text.on-inverted}.
Reading the semantic tokens directly would work and would be shorter. The indirection is what lets a theme restyle Box borders without moving every other border in the system. It is the same argument as the button tokens in chapter 3, applied to layout.
The border and invert rules have no comma fallback. Style Dictionary generates all four properties, so a fallback would only ever fire if the token build had not run, in which case the rest of the library is broken too.
// src/layouts/Box/Box.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './box.css'
export interface RudiBoxProps extends React.HTMLAttributes<HTMLElement> {
padding?: string
bordered?: boolean
invert?: boolean
as?: React.ElementType
children?: React.ReactNode
}
export const RudiBox = forwardRef<HTMLElement, RudiBoxProps>(function RudiBox(
{
padding,
bordered = false,
invert = false,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (padding) customProperties['--box-padding'] = padding
return (
<Element
ref={ref}
className={cn(
'rudi-box',
bordered && 'rudi-box--bordered',
invert && 'rudi-box--invert',
className,
)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
Every primitive in this chapter follows the same forwardRef pattern. The ref is typed as HTMLElement in all cases for the same reason discussed in Chapter 4: the as prop accepts any element type, so a more specific ref type (for example, HTMLDivElement) would be incorrect when the consumer passes as="section". HTMLElement is the accurate common base.
Center
The Center constrains content to a maximum width and horizontally centers it. It’s the primitive you wrap around page-level content to prevent text lines from stretching to the full viewport width (which makes them difficult to read past about 70 characters per line).
.rudi-center {
--center-max-width: var(--rudi-layout-center-max-width, 60rem);
--center-gutters: var(--rudi-layout-center-gutters, 1rem);
box-sizing: content-box;
max-inline-size: var(--center-max-width);
margin-inline: auto;
padding-inline: var(--center-gutters);
}
.rudi-center--intrinsic {
display: flex;
flex-direction: column;
align-items: center;
}
The box-sizing: content-box declaration is deliberate. It means the max-inline-size applies to the content only, and the padding-inline (gutters) is added outside that. The total width is max-width + gutters. Without this, the gutters eat into the content width, and your max-width is effectively narrower than specified.
The intrinsic variant centers its children at their own content width instead of stretching them to the max width. This is useful for centering a button or a narrow form that shouldn’t stretch to 60rem.
// src/layouts/Center/Center.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './center.css'
export interface RudiCenterProps extends React.HTMLAttributes<HTMLElement> {
maxWidth?: string
gutters?: string
intrinsic?: boolean
as?: React.ElementType
children?: React.ReactNode
}
export const RudiCenter = forwardRef<HTMLElement, RudiCenterProps>(function RudiCenter(
{
maxWidth,
gutters,
intrinsic = false,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (maxWidth) customProperties['--center-max-width'] = maxWidth
if (gutters) customProperties['--center-gutters'] = gutters
return (
<Element
ref={ref}
className={cn(
'rudi-center',
intrinsic && 'rudi-center--intrinsic',
className,
)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
Cluster
The Cluster arranges inline-like children (tags, buttons, badges, navigation links) in a horizontal row with consistent spacing. When the children don’t fit in one row, they wrap to the next line. The gap property handles both horizontal and vertical spacing between wrapped rows.
.rudi-cluster {
--cluster-space: var(--rudi-layout-cluster-space, 1rem);
--cluster-justify: flex-start;
--cluster-align: center;
display: flex;
flex-wrap: wrap;
gap: var(--cluster-space);
justify-content: var(--cluster-justify);
align-items: var(--cluster-align);
}
// src/layouts/Cluster/Cluster.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './cluster.css'
export interface RudiClusterProps extends React.HTMLAttributes<HTMLElement> {
space?: string
justify?:
| 'flex-start'
| 'flex-end'
| 'center'
| 'space-between'
| 'space-around'
align?: 'flex-start' | 'flex-end' | 'center' | 'baseline' | 'stretch'
as?: React.ElementType
children?: React.ReactNode
}
export const RudiCluster = forwardRef<HTMLElement, RudiClusterProps>(function RudiCluster(
{
space,
justify,
align,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (space) customProperties['--cluster-space'] = space
if (justify) customProperties['--cluster-justify'] = justify
if (align) customProperties['--cluster-align'] = align
return (
<Element
ref={ref}
className={cn('rudi-cluster', className)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
A common use case is a header with a logo on the left and navigation links on the right.
<RudiCluster justify="space-between" align="center">
<Logo />
<RudiCluster space="0.5rem">
<NavLink>About</NavLink>
<NavLink>Blog</NavLink>
<NavLink>Contact</NavLink>
</RudiCluster>
</RudiCluster>
Sidebar
The Sidebar is the most complex layout primitive. It creates a two-panel layout where one panel (the sidebar) has a fixed width and the other (the content) fills the remaining space. When the content panel would be squeezed below a minimum width, both panels stack vertically. No media queries. The layout responds to its own container, not the viewport.
The technique uses flexbox with asymmetric flex-grow values:
.rudi-sidebar {
--sidebar-width: var(--rudi-layout-sidebar-width, 20rem);
--sidebar-content-min: var(--rudi-layout-sidebar-content-min, 50%);
--sidebar-space: var(--rudi-layout-sidebar-space, 1.5rem);
display: flex;
flex-wrap: wrap;
gap: var(--sidebar-space);
}
.rudi-sidebar > :first-child {
flex-basis: var(--sidebar-width);
flex-grow: 1;
}
.rudi-sidebar > :last-child {
flex-basis: 0;
flex-grow: 999;
min-inline-size: var(--sidebar-content-min);
}
.rudi-sidebar--right > :first-child {
flex-basis: 0;
flex-grow: 999;
min-inline-size: var(--sidebar-content-min);
}
.rudi-sidebar--right > :last-child {
flex-basis: var(--sidebar-width);
flex-grow: 1;
}
.rudi-sidebar--no-stretch {
align-items: flex-start;
}
The key insight is flex-grow: 999 on the content panel. Because this value is so much larger than the sidebar’s flex-grow: 1, the content panel consumes all available space beyond the sidebar’s flex-basis. The min-inline-size: 50% on the content panel forces wrapping: when the content would be narrower than 50% of the container, it wraps below the sidebar and both panels take full width.
This is an intrinsic layout. The same Sidebar component works in a 1200px container (side by side) and a 400px container (stacked) without any changes to the component or its props. The container’s width is the only input.
// src/layouts/Sidebar/Sidebar.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './sidebar.css'
export interface RudiSidebarProps extends React.HTMLAttributes<HTMLElement> {
side?: 'left' | 'right'
sideWidth?: string
contentMin?: string
space?: string
noStretch?: boolean
as?: React.ElementType
children?: React.ReactNode
}
export const RudiSidebar = forwardRef<HTMLElement, RudiSidebarProps>(function RudiSidebar(
{
side = 'left',
sideWidth,
contentMin,
space,
noStretch = false,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (sideWidth) customProperties['--sidebar-width'] = sideWidth
if (contentMin) customProperties['--sidebar-content-min'] = contentMin
if (space) customProperties['--sidebar-space'] = space
return (
<Element
ref={ref}
className={cn(
'rudi-sidebar',
side === 'right' && 'rudi-sidebar--right',
noStretch && 'rudi-sidebar--no-stretch',
className,
)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
The Sidebar expects exactly two children. The first child is the sidebar panel (when side="left"), and the second is the content panel. When side="right", the roles reverse. The component doesn’t enforce this at runtime, but the companion repository’s documentation and stories clarify the expectation.
Switcher
The Switcher creates an N-column layout that collapses to a single column when the container is narrower than a threshold. Unlike the Sidebar (which has one fixed-width panel), the Switcher divides space equally among all its children.
The technique uses a flex-basis calculation that evaluates to either a very large positive number (forcing wrapping) or zero (allowing horizontal layout):
.rudi-switcher {
--switcher-threshold: var(--rudi-layout-switcher-threshold, 30rem);
--switcher-space: var(--rudi-layout-switcher-space, 1.5rem);
display: flex;
flex-wrap: wrap;
gap: var(--switcher-space);
}
.rudi-switcher > * {
flex-grow: 1;
flex-basis: calc((var(--switcher-threshold) - 100%) * 999);
}
The calc() expression is the heart of the trick. When the container is wider than the threshold, (threshold - 100%) is negative, and multiplying by 999 produces a very large negative number. Since flex-basis can’t be negative, the browser clamps it to zero, and the children share the space equally in a row. When the container is narrower than the threshold, the result is positive, and each child’s flex-basis exceeds the container width, forcing every child to wrap onto its own line.
// src/layouts/Switcher/Switcher.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './switcher.css'
export interface RudiSwitcherProps extends React.HTMLAttributes<HTMLElement> {
threshold?: string
space?: string
limit?: number
as?: React.ElementType
children?: React.ReactNode
}
export const RudiSwitcher = forwardRef<HTMLElement, RudiSwitcherProps>(
function RudiSwitcher(
{
threshold,
space,
limit,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (threshold) customProperties['--switcher-threshold'] = threshold
if (space) customProperties['--switcher-space'] = space
return (
<Element
ref={ref}
className={cn('rudi-switcher', className)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
},
)
The intent behind it is real. Every Layout’s Switcher supports capping how many items may share a row: past that count, every child goes full-width instead. The implementation is a quantity query like .rudi-switcher > :nth-last-child(n + 4), .rudi-switcher > :nth-last-child(n + 4) ~ * setting flex-basis: 100%, and the awkward part is that the 4 has to be baked into a selector. CSS :nth-child() takes selector math, not custom properties, so :nth-child(n + var(--limit)) is invalid, and the value cannot arrive through the same inline-custom-property channel every other prop uses.
There are two workable approaches. Generate a small scoped <style> element per instance, keyed on the limit. Or ship a fixed set of modifier classes (rudi-switcher--limit-2 through --limit-6) and map the prop onto them, which covers the realistic range without runtime style injection. The second is less clever and easier to reason about.
Either way, the core switching behavior works without it, which is why it has stayed a stub.
Grid
The Grid creates a responsive grid without media queries. You specify a minimum cell width, and the browser determines how many columns fit. When the container narrows, columns drop off naturally.
.rudi-grid {
--grid-min-cell: var(--rudi-layout-grid-min-cell, 15rem);
--grid-space: var(--rudi-layout-grid-space, 1.5rem);
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--grid-min-cell), 100%), 1fr)
);
gap: var(--grid-space);
}
The min(var(--grid-min-cell), 100%) inside the minmax() is critical. Without it, cells wider than their container overflow. The min() function ensures cells never exceed 100% of the container width, so on a very narrow screen, you get a single column of full-width cells without any overflow.
// src/layouts/Grid/Grid.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './grid.css'
export interface RudiGridProps extends React.HTMLAttributes<HTMLElement> {
minCellWidth?: string
space?: string
as?: React.ElementType
children?: React.ReactNode
}
export const RudiGrid = forwardRef<HTMLElement, RudiGridProps>(function RudiGrid(
{
minCellWidth,
space,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (minCellWidth) customProperties['--grid-min-cell'] = minCellWidth
if (space) customProperties['--grid-space'] = space
return (
<Element
ref={ref}
className={cn('rudi-grid', className)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
Cover
The Cover vertically centers a principal child element within a minimum-height container. It’s the primitive for hero sections, login pages, and any full-viewport layout where the main content should be vertically centered with optional header and footer elements pinned to the top and bottom.
.rudi-cover {
--cover-min-height: var(--rudi-layout-cover-min-height, 100vh);
--cover-space: var(--rudi-layout-cover-space, 1.5rem);
display: flex;
flex-direction: column;
min-block-size: var(--cover-min-height);
}
.rudi-cover > * {
margin-block: var(--cover-space);
}
.rudi-cover > :first-child:not(.rudi-cover__centered) {
margin-block-start: 0;
}
.rudi-cover > :last-child:not(.rudi-cover__centered) {
margin-block-end: 0;
}
.rudi-cover > .rudi-cover__centered {
margin-block: auto;
}
The margin-block: auto on the centered child is the vertical centering mechanism. In a flex column context, auto margin absorbs available space, pushing the element to the center. The first and last child overrides prevent extra spacing at the edges, so a header sits flush at the top and a footer sits flush at the bottom.
// src/layouts/Cover/Cover.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './cover.css'
export interface RudiCoverProps extends React.HTMLAttributes<HTMLElement> {
minHeight?: string
space?: string
as?: React.ElementType
children?: React.ReactNode
}
export const RudiCover = forwardRef<HTMLElement, RudiCoverProps>(function RudiCover(
{
minHeight,
space,
as: Element = 'div',
className,
style,
children,
...props
},
ref,
) {
const customProperties: Record<string, string> = {}
if (minHeight) customProperties['--cover-min-height'] = minHeight
if (space) customProperties['--cover-space'] = space
return (
<Element
ref={ref}
className={cn('rudi-cover', className)}
style={{ ...customProperties, ...style } as React.CSSProperties}
{...props}
>
{children}
</Element>
)
})
The consumer marks the centered child with the rudi-cover__centered class:
<RudiCover>
<header>Site header</header>
<main className="rudi-cover__centered">
<h1>Welcome</h1>
<p>This content is vertically centered.</p>
</main>
<footer>Site footer</footer>
</RudiCover>
An alternative approach is to export a CoverCentered wrapper component that applies the class automatically. Either approach works. The class-based approach is simpler and avoids an extra component.
Composing primitives
These primitives become most useful when nested. A complete page layout requires no bespoke CSS, just composition:
<RudiStack space="0">
<RudiBox as="header" padding="1rem" bordered>
<RudiCenter maxWidth="72rem">
<RudiCluster justify="space-between" align="center">
<strong>App Name</strong>
<RudiCluster space="0.5rem">
<a href="/docs">Docs</a>
<a href="/settings">Settings</a>
</RudiCluster>
</RudiCluster>
</RudiCenter>
</RudiBox>
<RudiCenter maxWidth="72rem" gutters="1.5rem">
<RudiSidebar sideWidth="14rem" contentMin="60%" space="2rem">
<RudiStack as="nav" space="0.25rem">
<a href="/dashboard">Dashboard</a>
<a href="/projects">Projects</a>
<a href="/team">Team</a>
</RudiStack>
<RudiStack as="main" space="2rem">
<h1>Dashboard</h1>
<RudiSwitcher threshold="20rem" space="1rem">
<RudiBox bordered padding="1.5rem">
Metric A
</RudiBox>
<RudiBox bordered padding="1.5rem">
Metric B
</RudiBox>
<RudiBox bordered padding="1.5rem">
Metric C
</RudiBox>
</RudiSwitcher>
<RudiGrid minCellWidth="16rem" space="1rem">
<RudiBox bordered padding="1rem">
Card 1
</RudiBox>
<RudiBox bordered padding="1rem">
Card 2
</RudiBox>
<RudiBox bordered padding="1rem">
Card 3
</RudiBox>
<RudiBox bordered padding="1rem">
Card 4
</RudiBox>
</RudiGrid>
</RudiStack>
</RudiSidebar>
</RudiCenter>
</RudiStack>
This layout handles narrow viewports automatically. The Sidebar stacks when the content panel would be too narrow. The Switcher collapses the metric cards to a single column. The Grid drops columns as space decreases. No breakpoints, no media queries, no viewport-specific code.
The companion repository’s example pages are the best place to see this at scale. The CRM dashboard below is built from these primitives and nothing else structural:
Attribution
The layout primitives in this guide are inspired by the intrinsic layout patterns described in Every Layout by Heydon Pickering and Andy Bell. Their work on algorithmic, context-independent CSS layout is recommended reading for anyone building layout systems.
What you have now
Eight layout primitives, all following the same pattern: token-driven CSS, React component wrapper, as prop for semantic HTML, className merging, prop-based overrides via inline custom properties. The layout system is usable independently of the UI components.
Before moving to interactive components, the next chapter adds the typography layer: Heading, Text, and Prose components that handle your UI’s text content.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.