Skip to content

Split hero with live panel

Copy on the left, a real component composition on the right — not a screenshot.

Headersintermediateherosplitdashboardproduct

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 { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Metric } from '@/components/ui/metric'

/**
 * Split header with a visual panel
 *
 * Copy on the left, a live component composition on the right. The right panel
 * is real UI rather than a screenshot, which is both more honest and more
 * useful: it stays correct when the theme changes.
 *
 * Below `lg` the panel moves below the copy rather than shrinking, because a
 * squeezed dashboard preview communicates nothing.
 */
export default function SplitVisualHeader() {
  return (
    <header className="overflow-hidden border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
          <div>
            <p className="label-caps text-accent">Built for product teams</p>
            <h1 className="display-type mt-4 text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
              One system. Every surface in your product.
            </h1>
            <p className="mt-5 max-w-lg text-md text-ink-muted">
              Density is a theme axis, not a table prop — so your admin console and your marketing
              site can finally share a component set.
            </p>
            <div className="mt-8 flex flex-wrap gap-3">
              <ButtonLink href="/components" trailingIcon={<ArrowRight className="size-4" />}>
                Explore components
              </ButtonLink>
              <ButtonLink href="/docs/design-tokens" variant="outline">
                See the tokens
              </ButtonLink>
            </div>
          </div>

          <div className="relative">
            <div
              className="grid-paper absolute -inset-6 rounded-xl opacity-60"
              aria-hidden="true"
            />
            <div className="relative rounded-xl border border-line bg-surface p-5 shadow-lg">
              <div className="flex items-center justify-between">
                <p className="text-sm font-semibold text-ink-strong">Workspace overview</p>
                <p className="label-caps text-ink-subtle">Last 30 days</p>
              </div>
              <div className="mt-4 grid grid-cols-2 gap-3">
                <Metric
                  label="Active seats"
                  value="1,284"
                  delta={4.1}
                  sparkline={[40, 42, 41, 45, 47, 49, 52, 54]}
                />
                <Metric
                  label="p95 latency"
                  value="182 ms"
                  delta={-8.3}
                  invertTrend
                  sparkline={[30, 28, 31, 26, 24, 22, 20, 18]}
                />
              </div>
              <div className="mt-3 rounded-lg border border-line bg-surface-sunken p-3">
                <p className="label-caps text-ink-subtle">Deployment</p>
                <p className="mt-1 text-sm text-ink">
                  <span className="font-medium text-ink-strong">v1.4.2</span> shipped to EU West
                </p>
              </div>
            </div>
          </div>
        </div>
      </Container>
    </header>
  )
}

components/blocks/headers/split-visual.tsx

import { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Metric } from '@/components/ui/metric'

/**
 * Split header with a visual panel
 *
 * Copy on the left, a live component composition on the right. The right panel
 * is real UI rather than a screenshot, which is both more honest and more
 * useful: it stays correct when the theme changes.
 *
 * Below `lg` the panel moves below the copy rather than shrinking, because a
 * squeezed dashboard preview communicates nothing.
 */
export default function SplitVisualHeader() {
  return (
    <header className="overflow-hidden border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
          <div>
            <p className="label-caps text-accent">Built for product teams</p>
            <h1 className="display-type mt-4 text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
              One system. Every surface in your product.
            </h1>
            <p className="mt-5 max-w-lg text-md text-ink-muted">
              Density is a theme axis, not a table prop — so your admin console and your marketing
              site can finally share a component set.
            </p>
            <div className="mt-8 flex flex-wrap gap-3">
              <ButtonLink href="/components" trailingIcon={<ArrowRight className="size-4" />}>
                Explore components
              </ButtonLink>
              <ButtonLink href="/docs/design-tokens" variant="outline">
                See the tokens
              </ButtonLink>
            </div>
          </div>

          <div className="relative">
            <div
              className="grid-paper absolute -inset-6 rounded-xl opacity-60"
              aria-hidden="true"
            />
            <div className="relative rounded-xl border border-line bg-surface p-5 shadow-lg">
              <div className="flex items-center justify-between">
                <p className="text-sm font-semibold text-ink-strong">Workspace overview</p>
                <p className="label-caps text-ink-subtle">Last 30 days</p>
              </div>
              <div className="mt-4 grid grid-cols-2 gap-3">
                <Metric
                  label="Active seats"
                  value="1,284"
                  delta={4.1}
                  sparkline={[40, 42, 41, 45, 47, 49, 52, 54]}
                />
                <Metric
                  label="p95 latency"
                  value="182 ms"
                  delta={-8.3}
                  invertTrend
                  sparkline={[30, 28, 31, 26, 24, 22, 20, 18]}
                />
              </div>
              <div className="mt-3 rounded-lg border border-line bg-surface-sunken p-3">
                <p className="label-caps text-ink-subtle">Deployment</p>
                <p className="mt-1 text-sm text-ink">
                  <span className="font-medium text-ink-strong">v1.4.2</span> shipped to EU West
                </p>
              </div>
            </div>
          </div>
        </div>
      </Container>
    </header>
  )
}

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

When the product is visual. The right panel is built from Foundry components, so it restyles with the theme and can never show a stale screenshot of an older release.

  • The panel moves below the copy at `lg` rather than shrinking — a squeezed dashboard preview communicates nothing.
  • Grid-paper behind the panel gives depth without a gradient or a glow.

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-column at lg
  • Stacked below lg
  • Live metric panel

Accessibility

Decorative background
The grid layer is `aria-hidden` and non-interactive.
Real content
The panel is genuine markup, so its numbers are readable by screen readers rather than baked into an image.

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.