Skip to content

Editorial hero

A title page: rule, eyebrow, oversized serif statement, standfirst at the reading measure.

HeadersstarterFeaturedheroeditorialtypographyserif

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 Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'

/**
 * Editorial header
 *
 * A hero built like a title page: a rule, an eyebrow, an oversized serif
 * statement and a standfirst set at the reading measure. No image, no card,
 * no gradient — the typography carries the whole thing, which is why the
 * display size steps three times between 390px and 1440px.
 */
export default function EditorialHeader() {
  return (
    <header className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="border-t-2 border-ink pt-6">
          <p className="label-caps text-ink-subtle">Foundry 1.0 · March 2026</p>
          <h1 className="display-type mt-6 max-w-4xl text-4xl leading-[1.05] font-semibold text-ink-strong sm:text-5xl lg:text-6xl">
            A frontend system from which many products can be built.
          </h1>
          <p className="mt-6 max-w-[46rem] text-md leading-relaxed text-ink-muted sm:text-lg">
            Primitives, navigation, forms, sections, page patterns and complete starter products —
            all composed from one token system, documented against their own source.
          </p>
          <div className="mt-8 flex flex-wrap items-center gap-3">
            <ButtonLink
              href="/components"
              size="lg"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Browse the catalogue
            </ButtonLink>
            <Link
              href="/docs/getting-started"
              className="text-sm font-medium text-ink underline underline-offset-4 hover:text-accent"
            >
              Read the getting-started guide
            </Link>
          </div>
        </div>
      </Container>
    </header>
  )
}

components/blocks/headers/editorial.tsx

import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'

/**
 * Editorial header
 *
 * A hero built like a title page: a rule, an eyebrow, an oversized serif
 * statement and a standfirst set at the reading measure. No image, no card,
 * no gradient — the typography carries the whole thing, which is why the
 * display size steps three times between 390px and 1440px.
 */
export default function EditorialHeader() {
  return (
    <header className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="border-t-2 border-ink pt-6">
          <p className="label-caps text-ink-subtle">Foundry 1.0 · March 2026</p>
          <h1 className="display-type mt-6 max-w-4xl text-4xl leading-[1.05] font-semibold text-ink-strong sm:text-5xl lg:text-6xl">
            A frontend system from which many products can be built.
          </h1>
          <p className="mt-6 max-w-[46rem] text-md leading-relaxed text-ink-muted sm:text-lg">
            Primitives, navigation, forms, sections, page patterns and complete starter products —
            all composed from one token system, documented against their own source.
          </p>
          <div className="mt-8 flex flex-wrap items-center gap-3">
            <ButtonLink
              href="/components"
              size="lg"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Browse the catalogue
            </ButtonLink>
            <Link
              href="/docs/getting-started"
              className="text-sm font-medium text-ink underline underline-offset-4 hover:text-accent"
            >
              Read the getting-started guide
            </Link>
          </div>
        </div>
      </Container>
    </header>
  )
}

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}
    />
  )
}

components/ui/button.tsx

import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'

/**
 * Button
 *
 * The whole action surface of Foundry in one component. It is intentionally a
 * *shared* component (no `'use client'`): rendered from a Server Component it
 * ships zero JavaScript, and it upgrades to a client island automatically when
 * a Client Component imports it.
 *
 * Height, padding and radius resolve from density and radius tokens, so a
 * button restyles itself when the density or palette axis changes. Focus is
 * handled once, globally, by the `:focus-visible` rule in `globals.css`.
 */
const buttonVariants = variants(
  cn(
    'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
    'transition-colors duration-150 ease-standard select-none',
    'disabled:pointer-events-none disabled:opacity-50',
    'aria-disabled:pointer-events-none aria-disabled:opacity-50',
  ),
  {
    variants: {
      variant: {
        primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
        secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
        outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
        ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
        destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
        success: 'bg-success text-white hover:opacity-90 active:opacity-80',
        warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
        link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
        soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
      },
      size: {
        sm: 'h-control-sm px-3 text-xs rounded-md',
        md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
        lg: 'h-control-lg px-5 text-base rounded-md',
        icon: 'h-control w-control p-0 rounded-md',
        'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
      },
      block: { true: 'w-full', false: '' },
    },
    defaultVariants: { variant: 'primary', size: 'md', block: false },
    compound: [
      { variant: 'link', size: 'sm', class: 'h-auto px-0' },
      { variant: 'link', size: 'md', class: 'h-auto px-0' },
      { variant: 'link', size: 'lg', class: 'h-auto px-0' },
    ],
  },
)

export type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'outline'
  | 'ghost'
  | 'destructive'
  | 'success'
  | 'warning'
  | 'link'
  | 'soft'

export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  /** Swaps content for a spinner while preserving the button's measured width. */
  loading?: boolean
  /** Announced by assistive tech while `loading` is true. */
  loadingLabel?: string
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
}

export function Button({
  variant = 'primary',
  size = 'md',
  block = false,
  loading = false,
  loadingLabel = 'Working',
  leadingIcon,
  trailingIcon,
  className,
  children,
  disabled,
  type = 'button',
  ...props
}: ButtonProps) {
  return (
    <button
      type={type}
      className={buttonVariants({ variant, size, block, className })}
      disabled={disabled ?? loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? (
        <>
          {/* Label stays in the DOM but hidden so the control never collapses
              to spinner width halfway through an interaction. */}
          <span className="invisible flex items-center gap-2" aria-hidden="true">
            {leadingIcon}
            {children}
            {trailingIcon}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner size="sm" />
            <span className="sr-only">{loadingLabel}</span>
          </span>
        </>
      ) : (
        <>
          {leadingIcon}
          {children}
          {trailingIcon}
        </>
      )}
    </button>
  )
}

export interface ButtonLinkProps {
  href: string
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  className?: string
  children?: ReactNode
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
  'aria-label'?: string
  'aria-current'?: 'page' | 'step' | 'true' | undefined
  target?: string
  rel?: string
  prefetch?: boolean
}

/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
  href,
  variant = 'primary',
  size = 'md',
  block = false,
  className,
  children,
  leadingIcon,
  trailingIcon,
  ...props
}: ButtonLinkProps) {
  return (
    <Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
      {leadingIcon}
      {children}
      {trailingIcon}
    </Link>
  )
}

export { buttonVariants }

Demo source — adapt to your project. Foundry is not published as a package.

Usage

When the words are the product. No image, no card, no gradient — which means the display size has to do the work, and steps three times between 390px and 1440px.

  • Keep the standfirst at the prose measure even when the heading is full width.
  • The top rule is 2px and uses ink rather than the border token, so it reads as typographic, not structural.

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.

  • Rule and eyebrow
  • Three-step display scale
  • Standfirst at prose measure

Accessibility

One h1
The statement is the page `h1`; the eyebrow is a paragraph, not a heading.
Balance
`text-wrap: balance` on headings prevents orphaned words at any width.

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.