Skip to content

Alternating feature rows

Copy and visual swapping sides down the page, with DOM order held constant.

MarketingintermediateFeaturedfeaturesalternatingsplitnarrative

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 { 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.