Alternating feature rows
Copy and visual swapping sides down the page, with DOM order held constant.
Live preview
Source
This exact file renders the preview above.
import { Check } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Metric } from '@/components/ui/metric'
import { Progress } from '@/components/ui/progress'
import { Status } from '@/components/ui/status'
/**
* Alternating feature rows
*
* Copy and visual swapping sides down the page. The DOM order stays
* copy-then-visual on every row; only the grid column order flips at `lg`, so
* a screen-reader user always hears the explanation before the illustration.
*/
const rows = [
{
eyebrow: 'Theming',
title: 'Three axes that actually compose',
body: 'Scheme, palette and density are independent. Corporate + dark + compact is a real, authored combination — not an accident of cascade order.',
points: ['Five palettes', 'Three densities', 'No flash on load'],
visual: 'progress' as const,
},
{
eyebrow: 'Architecture',
title: 'Server components by default',
body: 'Sections are static markup. Interactivity is scoped to small islands, so a marketing page ships almost no JavaScript at all.',
points: ['Static generation', 'Small client islands', 'No runtime CSS'],
visual: 'metrics' as const,
},
]
export default function AlternatingFeatures() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="flex flex-col gap-16 lg:gap-24">
{rows.map((row, index) => (
<div key={row.title} className="grid items-center gap-8 lg:grid-cols-2 lg:gap-16">
<div className={index % 2 === 1 ? 'lg:order-2' : undefined}>
<p className="label-caps text-accent">{row.eyebrow}</p>
<h2 className="display-type mt-3 text-xl font-semibold text-ink-strong sm:text-2xl">
{row.title}
</h2>
<p className="mt-4 max-w-lg text-md text-ink-muted">{row.body}</p>
<ul className="mt-6 flex flex-col gap-2">
{row.points.map((point) => (
<li key={point} className="flex items-center gap-2 text-sm text-ink">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{point}
</li>
))}
</ul>
</div>
<div className={index % 2 === 1 ? 'lg:order-1' : undefined}>
<div className="rounded-xl border border-line bg-surface p-6 shadow-sm">
{row.visual === 'progress' ? (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-ink-strong">Theme adoption</p>
<Badge tone="accent">Live</Badge>
</div>
<Progress value={100} tone="success" label="Editorial" showValue />
<Progress value={72} label="Corporate" showValue />
<Progress value={45} label="Minimal" showValue />
<Progress value={28} tone="warning" label="Warm" showValue />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Metric
label="JS shipped"
value="14 kB"
delta={-78}
invertTrend
appearance="plain"
/>
<Metric
label="LCP"
value="0.9 s"
delta={-41}
invertTrend
appearance="plain"
/>
</div>
<div className="border-t border-line-subtle pt-4">
<Status
appearance="pill"
kind="operational"
label="All routes statically generated"
/>
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
</Container>
</section>
)
}
components/blocks/sections/features/alternating.tsx
import { Check } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Metric } from '@/components/ui/metric'
import { Progress } from '@/components/ui/progress'
import { Status } from '@/components/ui/status'
/**
* Alternating feature rows
*
* Copy and visual swapping sides down the page. The DOM order stays
* copy-then-visual on every row; only the grid column order flips at `lg`, so
* a screen-reader user always hears the explanation before the illustration.
*/
const rows = [
{
eyebrow: 'Theming',
title: 'Three axes that actually compose',
body: 'Scheme, palette and density are independent. Corporate + dark + compact is a real, authored combination — not an accident of cascade order.',
points: ['Five palettes', 'Three densities', 'No flash on load'],
visual: 'progress' as const,
},
{
eyebrow: 'Architecture',
title: 'Server components by default',
body: 'Sections are static markup. Interactivity is scoped to small islands, so a marketing page ships almost no JavaScript at all.',
points: ['Static generation', 'Small client islands', 'No runtime CSS'],
visual: 'metrics' as const,
},
]
export default function AlternatingFeatures() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="flex flex-col gap-16 lg:gap-24">
{rows.map((row, index) => (
<div key={row.title} className="grid items-center gap-8 lg:grid-cols-2 lg:gap-16">
<div className={index % 2 === 1 ? 'lg:order-2' : undefined}>
<p className="label-caps text-accent">{row.eyebrow}</p>
<h2 className="display-type mt-3 text-xl font-semibold text-ink-strong sm:text-2xl">
{row.title}
</h2>
<p className="mt-4 max-w-lg text-md text-ink-muted">{row.body}</p>
<ul className="mt-6 flex flex-col gap-2">
{row.points.map((point) => (
<li key={point} className="flex items-center gap-2 text-sm text-ink">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{point}
</li>
))}
</ul>
</div>
<div className={index % 2 === 1 ? 'lg:order-1' : undefined}>
<div className="rounded-xl border border-line bg-surface p-6 shadow-sm">
{row.visual === 'progress' ? (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-ink-strong">Theme adoption</p>
<Badge tone="accent">Live</Badge>
</div>
<Progress value={100} tone="success" label="Editorial" showValue />
<Progress value={72} label="Corporate" showValue />
<Progress value={45} label="Minimal" showValue />
<Progress value={28} tone="warning" label="Warm" showValue />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Metric
label="JS shipped"
value="14 kB"
delta={-78}
invertTrend
appearance="plain"
/>
<Metric
label="LCP"
value="0.9 s"
delta={-41}
invertTrend
appearance="plain"
/>
</div>
<div className="border-t border-line-subtle pt-4">
<Status
appearance="pill"
kind="operational"
label="All routes statically generated"
/>
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
</Container>
</section>
)
}
components/ui/progress.tsx
import { cn } from '@/lib/cn'
/**
* Progress
*
* Determinate by default. When `value` is omitted the bar renders as
* indeterminate and drops `aria-valuenow`, which is what tells assistive tech
* "in progress, duration unknown" rather than "0%".
*/
export interface ProgressProps {
/** 0–100. Omit for an indeterminate bar. */
value?: number
label?: string
/** Renders the numeric value beside the label. */
showValue?: boolean
/** Accessible name when no visible label is wanted. */
srLabel?: string
tone?: 'accent' | 'success' | 'warning' | 'danger'
size?: 'sm' | 'md'
className?: string
}
const tones = {
accent: 'bg-accent',
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-danger',
} as const
export function Progress({
value,
label,
showValue = false,
srLabel,
tone = 'accent',
size = 'md',
className,
}: ProgressProps) {
const clamped = value === undefined ? undefined : Math.max(0, Math.min(100, Math.round(value)))
return (
<div className={cn('w-full', className)}>
{(label || showValue) && (
<div className="mb-1.5 flex items-baseline justify-between gap-3">
{label ? <span className="text-xs font-medium text-ink">{label}</span> : <span />}
{showValue && clamped !== undefined ? (
<span className="font-mono text-xs text-ink-muted tabular-nums">{clamped}%</span>
) : null}
</div>
)}
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={clamped}
aria-label={label ? undefined : (srLabel ?? 'Progress')}
aria-valuetext={clamped === undefined ? 'In progress' : `${clamped}%`}
className={cn(
'w-full overflow-hidden rounded-full bg-surface-sunken',
size === 'sm' ? 'h-1' : 'h-2',
)}
>
{clamped === undefined ? (
<div className={cn('h-full w-2/5 animate-pulse-token rounded-full', tones[tone])} />
) : (
<div
className={cn(
'h-full rounded-full transition-[width] duration-300 ease-standard',
tones[tone],
)}
style={{ width: `${clamped}%` }}
/>
)}
</div>
</div>
)
}
export interface ProgressRingProps {
value: number
size?: number
label?: string
tone?: keyof typeof tones
className?: string
}
/** Circular variant, for dashboard tiles where a bar would waste width. */
export function ProgressRing({
value,
size = 56,
label,
tone = 'accent',
className,
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(100, Math.round(value)))
const stroke = 5
const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius
const offset = circumference - (clamped / 100) * circumference
const strokeColor = {
accent: 'var(--color-accent)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
danger: 'var(--color-danger)',
}[tone]
return (
<div
className={cn('relative inline-flex items-center justify-center', className)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={clamped}
aria-label={label ?? 'Progress'}
>
<svg width={size} height={size} className="-rotate-90" aria-hidden="true">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--color-surface-sunken)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
<span className="absolute font-mono text-xs font-medium tabular-nums">{clamped}%</span>
</div>
)
}
components/ui/metric.tsx
import type { ReactNode } from 'react'
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Metric
*
* A single number with optional trend. The delta carries an arrow *and* a sign
* *and* a colour, so the direction survives a greyscale print or a colour
* vision deficiency.
*
* `<dl>` semantics pair the label with the value; the sparkline is decorative
* and hidden from assistive tech, since the number beside it is the content.
*/
export interface MetricProps {
label: string
value: string
/** Percentage change. Positive is not automatically "good" — see `invertTrend`. */
delta?: number
deltaLabel?: string
/** For metrics where down is good, e.g. churn or latency. */
invertTrend?: boolean
icon?: ReactNode
/** 0–1 values rendered as a decorative sparkline. */
sparkline?: number[]
appearance?: 'plain' | 'card'
className?: string
}
function Sparkline({ points }: { points: number[] }) {
if (points.length < 2) return null
const max = Math.max(...points)
const min = Math.min(...points)
const range = max - min || 1
const path = points
.map((point, index) => {
const x = (index / (points.length - 1)) * 100
const y = 28 - ((point - min) / range) * 26 - 1
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`
})
.join(' ')
return (
<svg viewBox="0 0 100 28" preserveAspectRatio="none" className="h-7 w-full" aria-hidden="true">
<path
d={path}
fill="none"
stroke="var(--color-accent)"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
)
}
export function Metric({
label,
value,
delta,
deltaLabel,
invertTrend = false,
icon,
sparkline,
appearance = 'card',
className,
}: MetricProps) {
const direction = delta === undefined ? 'flat' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'
const good = direction === 'flat' ? null : invertTrend ? direction === 'down' : direction === 'up'
const TrendIcon =
direction === 'up' ? ArrowUpRight : direction === 'down' ? ArrowDownRight : Minus
return (
<dl
className={cn(
'flex min-w-0 flex-col gap-1',
appearance === 'card' && 'rounded-lg border border-line bg-surface p-card',
className,
)}
>
<div className="flex items-center justify-between gap-2">
<dt className="label-caps truncate text-ink-subtle">{label}</dt>
{icon ? <span className="shrink-0 text-ink-subtle">{icon}</span> : null}
</div>
<dd className="display-type text-2xl leading-none font-semibold text-ink-strong tabular-nums">
{value}
</dd>
{delta !== undefined ? (
<dd
className={cn(
'flex items-center gap-1 text-xs font-medium',
good === null ? 'text-ink-muted' : good ? 'text-success' : 'text-danger',
)}
>
<TrendIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span>
{delta > 0 ? '+' : ''}
{delta}%
</span>
{deltaLabel ? <span className="text-ink-subtle">{deltaLabel}</span> : null}
</dd>
) : null}
{sparkline ? (
<dd className="mt-1">
<Sparkline points={sparkline} />
</dd>
) : null}
</dl>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Only the grid column order flips at lg; the DOM stays copy-then-visual on every row, so a screen-reader user always hears the explanation before the illustration.
- Two or three rows. Four becomes a scroll exercise.
- Each row needs a distinct visual, or the alternation looks like a template.
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 rows
- Order flip at lg only
- Live component visuals
Accessibility
- Stable reading order
- Visual order changes; source order does not.
- Real visuals
- Panels are live components, so their content is readable text.
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 sectionsFeature icon grid
Three columns of icon, title and paragraph — the workhorse feature section.
starterFeatured3 variantsSingle-feature spotlight
One capability given the room of a whole section, with a working demonstration.
intermediate3 variantsSaaS landing page
Eleven blocks in the order a subscription product needs: claim, proof, mechanism, evidence, price, objections, action.
intermediateFeatured4 variants