Skip to content

Ecommerce hero

A seasonal statement over an inverted field, with the category rail directly beneath.

Headersstarterherocommercecategoryseasonal

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 { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { productCategories } from '@/content/demo'

/**
 * Ecommerce header
 *
 * A seasonal statement over a tinted field, with the category rail directly
 * beneath. The rail matters more than the statement: most storefront visitors
 * arrive knowing roughly what they want, and the fastest thing a hero can do
 * is get out of their way.
 */
export default function EcommerceHeader() {
  return (
    <header className="border-b border-line bg-canvas">
      <div className="relative overflow-hidden bg-surface-inverse text-ink-inverse">
        <div className="grid-paper absolute inset-0 opacity-30" aria-hidden="true" />
        <Container className="relative py-16 sm:py-24">
          <Badge appearance="outline" className="border-white/30 text-current">
            Autumn collection
          </Badge>
          <h1 className="display-type mt-5 max-w-2xl text-4xl leading-tight font-semibold sm:text-5xl">
            Made for weather that cannot decide.
          </h1>
          <p className="mt-4 max-w-lg text-md opacity-85">
            Layers cut close enough for a city and sealed well enough for the walk home.
          </p>
          <div className="mt-8 flex flex-wrap gap-3">
            <ButtonLink
              href="/starters/ecommerce/preview/shop"
              variant="secondary"
              size="lg"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Shop the collection
            </ButtonLink>
            <ButtonLink
              href="/starters/ecommerce/preview/category/outerwear"
              variant="ghost"
              size="lg"
              className="text-current hover:bg-white/10"
            >
              Outerwear
            </ButtonLink>
          </div>
        </Container>
      </div>

      <Container className="py-4">
        <nav aria-label="Shop categories">
          <ul className="hide-scrollbar flex gap-2 overflow-x-auto">
            {productCategories.map((category) => (
              <li key={category.slug}>
                <Link
                  href={`/starters/ecommerce/preview/category/${category.slug}`}
                  className="flex h-9 items-center rounded-full border border-line px-4 text-sm font-medium whitespace-nowrap text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {category.name}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
      </Container>
    </header>
  )
}

components/blocks/headers/ecommerce.tsx

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

/**
 * Ecommerce header
 *
 * A seasonal statement over a tinted field, with the category rail directly
 * beneath. The rail matters more than the statement: most storefront visitors
 * arrive knowing roughly what they want, and the fastest thing a hero can do
 * is get out of their way.
 */
export default function EcommerceHeader() {
  return (
    <header className="border-b border-line bg-canvas">
      <div className="relative overflow-hidden bg-surface-inverse text-ink-inverse">
        <div className="grid-paper absolute inset-0 opacity-30" aria-hidden="true" />
        <Container className="relative py-16 sm:py-24">
          <Badge appearance="outline" className="border-white/30 text-current">
            Autumn collection
          </Badge>
          <h1 className="display-type mt-5 max-w-2xl text-4xl leading-tight font-semibold sm:text-5xl">
            Made for weather that cannot decide.
          </h1>
          <p className="mt-4 max-w-lg text-md opacity-85">
            Layers cut close enough for a city and sealed well enough for the walk home.
          </p>
          <div className="mt-8 flex flex-wrap gap-3">
            <ButtonLink
              href="/starters/ecommerce/preview/shop"
              variant="secondary"
              size="lg"
              trailingIcon={<ArrowRight className="size-4" />}
            >
              Shop the collection
            </ButtonLink>
            <ButtonLink
              href="/starters/ecommerce/preview/category/outerwear"
              variant="ghost"
              size="lg"
              className="text-current hover:bg-white/10"
            >
              Outerwear
            </ButtonLink>
          </div>
        </Container>
      </div>

      <Container className="py-4">
        <nav aria-label="Shop categories">
          <ul className="hide-scrollbar flex gap-2 overflow-x-auto">
            {productCategories.map((category) => (
              <li key={category.slug}>
                <Link
                  href={`/starters/ecommerce/preview/category/${category.slug}`}
                  className="flex h-9 items-center rounded-full border border-line px-4 text-sm font-medium whitespace-nowrap text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {category.name}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
      </Container>
    </header>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

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

Usage

Storefront visitors mostly arrive knowing roughly what they want, so the fastest thing a hero can do is get out of the way. The rail matters more than the statement.

  • Keep the statement to one line at desktop; it competes with the products otherwise.
  • The rail scrolls horizontally rather than wrapping, so the hero height stays predictable.

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.

  • Inverted statement panel
  • Category pill rail
  • Dual action

Accessibility

Rail landmark
The category rail is a labelled `<nav>` separate from the site navigation.
Inverted contrast
Foreground on the inverted panel comes from the ink-inverse token.

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.