Editorial hero
A title page: rule, eyebrow, oversized serif statement, standfirst at the reading measure.
Live preview
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.
Related
All navigationEditorial masthead
A publication masthead: oversized serif wordmark, issue line, and a scrolling section rail.
starter3 variantsEditorial page
A publication front page moving from lead article to progressively denser indexes.
starter4 variantsEditorial split hero
An asymmetric seven-and-four column hero where the statement and the note sit at different weights.
starterFeatured3 variants