Before and after columns
The problem in the reader's words on the left, the resolution on the right.
Live preview
Source
This exact file renders the preview above.
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
/**
* Before / after columns
*
* States the problem in the reader's own words on the left and the resolution
* on the right. Two columns of equal weight, because claiming the "after"
* column is twice as tall as the "before" one is a rhetorical trick that
* readers notice.
*/
const rows = [
{
before: 'Four teams, four button components, four opinions about what “medium” means.',
after: 'One control height token. Changing density changes every control at once.',
},
{
before:
'The documentation says the component takes a `size` prop. It was renamed last quarter.',
after: 'Documentation is generated from the file, and a test fails when the two drift.',
},
{
before: 'Dark mode was a filter applied late, so half the borders disappeared.',
after: 'The dark scheme is authored separately, with its own surface and ink ramps.',
},
{
before: 'The admin console could not use the marketing components — everything was too tall.',
after: 'Density is a theme axis, so both use the same components at different rhythms.',
},
]
export default function ComparisonColumnsFeatures() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
The four problems this was built to solve.
</h2>
</div>
<div className="mt-10 overflow-hidden rounded-xl border border-line">
<div className="grid grid-cols-1 border-b border-line bg-surface-sunken sm:grid-cols-2">
<p className="label-caps border-b border-line px-5 py-3 text-ink-subtle sm:border-r sm:border-b-0">
Without a system
</p>
<p className="label-caps px-5 py-3 text-accent">With Foundry</p>
</div>
<ul>
{rows.map((row) => (
<li
key={row.before}
className="grid grid-cols-1 border-b border-line-subtle last:border-0 sm:grid-cols-2"
>
<p className="border-b border-line-subtle bg-surface px-5 py-4 text-sm text-ink-muted sm:border-r sm:border-b-0">
{row.before}
</p>
<p className="bg-surface px-5 py-4 text-sm text-ink">{row.after}</p>
</li>
))}
</ul>
</div>
<Link
href="/docs/composition"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-accent underline underline-offset-4"
>
Read how composition is structured
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</Container>
</section>
)
}
components/blocks/sections/features/comparison-columns.tsx
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
/**
* Before / after columns
*
* States the problem in the reader's own words on the left and the resolution
* on the right. Two columns of equal weight, because claiming the "after"
* column is twice as tall as the "before" one is a rhetorical trick that
* readers notice.
*/
const rows = [
{
before: 'Four teams, four button components, four opinions about what “medium” means.',
after: 'One control height token. Changing density changes every control at once.',
},
{
before:
'The documentation says the component takes a `size` prop. It was renamed last quarter.',
after: 'Documentation is generated from the file, and a test fails when the two drift.',
},
{
before: 'Dark mode was a filter applied late, so half the borders disappeared.',
after: 'The dark scheme is authored separately, with its own surface and ink ramps.',
},
{
before: 'The admin console could not use the marketing components — everything was too tall.',
after: 'Density is a theme axis, so both use the same components at different rhythms.',
},
]
export default function ComparisonColumnsFeatures() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
The four problems this was built to solve.
</h2>
</div>
<div className="mt-10 overflow-hidden rounded-xl border border-line">
<div className="grid grid-cols-1 border-b border-line bg-surface-sunken sm:grid-cols-2">
<p className="label-caps border-b border-line px-5 py-3 text-ink-subtle sm:border-r sm:border-b-0">
Without a system
</p>
<p className="label-caps px-5 py-3 text-accent">With Foundry</p>
</div>
<ul>
{rows.map((row) => (
<li
key={row.before}
className="grid grid-cols-1 border-b border-line-subtle last:border-0 sm:grid-cols-2"
>
<p className="border-b border-line-subtle bg-surface px-5 py-4 text-sm text-ink-muted sm:border-r sm:border-b-0">
{row.before}
</p>
<p className="bg-surface px-5 py-4 text-sm text-ink">{row.after}</p>
</li>
))}
</ul>
</div>
<Link
href="/docs/composition"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-accent underline underline-offset-4"
>
Read how composition is structured
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</Container>
</section>
)
}
components/ui/layout.tsx
import type { ElementType, HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* Layout primitives
*
* Five zero-JavaScript building blocks that account for the majority of
* structure in the library. They exist so that spacing decisions are made once,
* against density tokens, instead of being re-typed as ad-hoc utilities in
* every section.
*/
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
/** `prose` narrows to a comfortable reading measure. */
size?: 'prose' | 'narrow' | 'default' | 'wide' | 'full'
as?: ElementType
/** Removes the responsive horizontal gutter. */
bleed?: boolean
}
const containerSizes = {
prose: 'max-w-[var(--layout-prose-max)]',
narrow: 'max-w-3xl',
default: 'max-w-[var(--layout-content-max)]',
wide: 'max-w-[110rem]',
full: 'max-w-none',
} as const
export function Container({
size = 'default',
as: Tag = 'div',
bleed = false,
className,
children,
...props
}: ContainerProps) {
return (
<Tag
className={cn(
'mx-auto w-full',
containerSizes[size],
!bleed && 'px-4 sm:px-6 lg:px-8',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export interface StackProps extends HTMLAttributes<HTMLDivElement> {
direction?: 'row' | 'column'
gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
wrap?: boolean
as?: ElementType
}
const gapMap = {
none: 'gap-0',
xs: 'gap-1',
sm: 'gap-2',
md: 'gap-gap',
lg: 'gap-stack',
xl: 'gap-8',
} as const
const alignMap = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
baseline: 'items-baseline',
} as const
const justifyMap = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
} as const
export function Stack({
direction = 'column',
gap = 'md',
align,
justify,
wrap = false,
as: Tag = 'div',
className,
children,
...props
}: StackProps) {
return (
<Tag
className={cn(
'flex',
direction === 'column' ? 'flex-col' : 'flex-row',
gapMap[gap],
align && alignMap[align],
justify && justifyMap[justify],
wrap && 'flex-wrap',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export interface GridProps extends HTMLAttributes<HTMLDivElement> {
/** Column count at the largest breakpoint; smaller breakpoints step down. */
cols?: 1 | 2 | 3 | 4 | 5 | 6
gap?: StackProps['gap']
as?: ElementType
/** Auto-fit tracks with a minimum width instead of a fixed column count. */
minItemWidth?: string
}
const colMap = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
} as const
export function Grid({
cols = 3,
gap = 'lg',
as: Tag = 'div',
minItemWidth,
className,
style,
children,
...props
}: GridProps) {
return (
<Tag
className={cn('grid', minItemWidth ? undefined : colMap[cols], gapMap[gap], className)}
style={
minItemWidth
? {
...style,
gridTemplateColumns: `repeat(auto-fit, minmax(min(${minItemWidth}, 100%), 1fr))`,
}
: style
}
{...props}
>
{children}
</Tag>
)
}
export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical'
/** Renders a centred text label interrupting the rule. */
label?: ReactNode
weight?: 'subtle' | 'default' | 'strong'
}
const dividerWeight = {
subtle: 'border-line-subtle',
default: 'border-line',
strong: 'border-line-strong',
} as const
export function Divider({
orientation = 'horizontal',
label,
weight = 'default',
className,
...props
}: DividerProps) {
if (orientation === 'vertical') {
return (
<div
role="separator"
aria-orientation="vertical"
className={cn('h-full w-px self-stretch border-l', dividerWeight[weight], className)}
{...props}
/>
)
}
if (label) {
return (
<div className={cn('flex items-center gap-3', className)} {...props}>
<span className={cn('h-px flex-1 border-t', dividerWeight[weight])} role="separator" />
<span className="label-caps text-ink-subtle">{label}</span>
<span className={cn('h-px flex-1 border-t', dividerWeight[weight])} aria-hidden="true" />
</div>
)
}
return (
<div
role="separator"
className={cn('w-full border-t', dividerWeight[weight], className)}
{...props}
/>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Two columns of equal weight, because making the after column twice as tall is a rhetorical trick that readers notice.
- Write the before column in the reader's language, not yours.
- Four or five pairs; more and the section becomes a spreadsheet.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Two equal columns
- Hairline rows
- Stacks below sm
Accessibility
- Column headers
- Both columns carry a visible label, so the comparison is not implied by position alone.
- Reflow
- Columns stack on mobile with their labels intact.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All sectionsMigration mapping
Old concepts mapped to new ones, with a note on what genuinely changes.
starter3 variantsIncluded and excluded checklist
What the product does and does not do, side by side.
starter3 variantsBefore and after figures
The same four measures twice, with the change carried by arrow, sign and colour.
starter3 variants