Skip to content

Before and after columns

The problem in the reader's words on the left, the resolution on the right.

Marketingstarterfeaturesbefore-afterproblemframing

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.