Skip to content

Ecommerce landing page

Product first, with everything above the grid existing only to reach it faster.

CommerceintermediateFeaturedecommercestorefrontproductscommerce

Live preview

Open the full page
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 EcommerceNavbar from '@/components/blocks/navigation/ecommerce'
import EcommerceHeader from '@/components/blocks/headers/ecommerce'
import ProductFeatureGrid from '@/components/blocks/sections/product/feature-grid'
import CollectionBanner from '@/components/blocks/sections/product/collection-banner'
import ProductComparisonRow from '@/components/blocks/sections/product/comparison-row'
import MasonryTestimonials from '@/components/blocks/sections/testimonials/masonry'
import InlineNewsletter from '@/components/blocks/sections/newsletter/inline'
import EcommerceFooter from '@/components/blocks/footers/ecommerce'

/**
 * Ecommerce landing page
 *
 * Product first. Everything above the product grid exists only to get a
 * visitor to it faster, which is why the hero is short and the category rail
 * sits immediately beneath it.
 */
export default function EcommerceLandingPattern() {
  return (
    <>
      <EcommerceNavbar />
      <main>
        <EcommerceHeader />
        <ProductFeatureGrid />
        <CollectionBanner />
        <ProductComparisonRow />
        <MasonryTestimonials />
        <InlineNewsletter />
      </main>
      <EcommerceFooter />
    </>
  )
}

components/patterns/ecommerce-landing.tsx

import EcommerceNavbar from '@/components/blocks/navigation/ecommerce'
import EcommerceHeader from '@/components/blocks/headers/ecommerce'
import ProductFeatureGrid from '@/components/blocks/sections/product/feature-grid'
import CollectionBanner from '@/components/blocks/sections/product/collection-banner'
import ProductComparisonRow from '@/components/blocks/sections/product/comparison-row'
import MasonryTestimonials from '@/components/blocks/sections/testimonials/masonry'
import InlineNewsletter from '@/components/blocks/sections/newsletter/inline'
import EcommerceFooter from '@/components/blocks/footers/ecommerce'

/**
 * Ecommerce landing page
 *
 * Product first. Everything above the product grid exists only to get a
 * visitor to it faster, which is why the hero is short and the category rail
 * sits immediately beneath it.
 */
export default function EcommerceLandingPattern() {
  return (
    <>
      <EcommerceNavbar />
      <main>
        <EcommerceHeader />
        <ProductFeatureGrid />
        <CollectionBanner />
        <ProductComparisonRow />
        <MasonryTestimonials />
        <InlineNewsletter />
      </main>
      <EcommerceFooter />
    </>
  )
}

components/blocks/navigation/ecommerce.tsx

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { Menu, Search, ShoppingBag, User, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { Badge } from '@/components/ui/badge'
import { SearchField } from '@/components/ui/search-field'
import { shopLinks } from '@/content/nav-demo'

/**
 * Ecommerce navbar
 *
 * Three jobs at once: category browsing, search, and the bag. The announcement
 * bar above is part of the pattern rather than a separate block, because its
 * height affects the sticky offset of everything below it.
 *
 * The bag count is rendered as text inside the link's accessible name, so it
 * is not conveyed by a small coloured circle alone.
 */
export default function EcommerceNavbar() {
  const [open, setOpen] = useState(false)
  const [searchOpen, setSearchOpen] = useState(false)
  const bagCount = 3

  return (
    <div className="w-full">
      <p className="bg-surface-inverse px-4 py-2 text-center text-xs text-ink-inverse">
        Free shipping on orders over $150 —{' '}
        <Link href="/starters/ecommerce/preview/shop" className="underline underline-offset-2">
          shop new arrivals
        </Link>
      </p>

      <div className="border-b border-line bg-surface">
        <div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4 sm:px-6">
          <button
            type="button"
            onClick={() => setOpen((value) => !value)}
            aria-expanded={open}
            aria-controls="commerce-nav-panel"
            className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
          >
            {open ? (
              <X className="size-5" aria-hidden="true" />
            ) : (
              <Menu className="size-5" aria-hidden="true" />
            )}
            <span className="sr-only">{open ? 'Close menu' : 'Open menu'}</span>
          </button>

          <Link
            href="/starters/ecommerce/preview"
            className="flex items-center gap-2 text-ink-strong"
          >
            <BrandMark className="size-5 text-accent" />
            <span className="display-type text-md font-semibold tracking-tight">Quarry</span>
          </Link>

          <nav aria-label="Shop categories" className="ml-6 hidden lg:block">
            <ul className="flex items-center gap-1">
              {shopLinks.map((link) => (
                <li key={link.href}>
                  <Link
                    href={link.href}
                    className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
              <li>
                <Link
                  href="/starters/ecommerce/preview/shop"
                  className="flex h-8 items-center gap-2 rounded-md px-3 text-sm font-medium text-accent transition-colors hover:bg-accent-soft"
                >
                  Sale
                  <Badge size="sm" tone="danger">
                    −30%
                  </Badge>
                </Link>
              </li>
            </ul>
          </nav>

          <div className="ml-auto flex items-center gap-1">
            <div className="hidden xl:block">
              <SearchField
                fieldSize="sm"
                placeholder="Search the shop"
                aria-label="Search the shop"
                className="w-56"
              />
            </div>
            <button
              type="button"
              onClick={() => setSearchOpen((value) => !value)}
              aria-expanded={searchOpen}
              aria-controls="commerce-search-panel"
              className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken xl:hidden"
            >
              <Search className="size-4.5" aria-hidden="true" />
              <span className="sr-only">{searchOpen ? 'Close search' : 'Open search'}</span>
            </button>
            <Link
              href="/starters/ecommerce/preview/account"
              className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken"
            >
              <User className="size-4.5" aria-hidden="true" />
              <span className="sr-only">Account</span>
            </Link>
            <Link
              href="/starters/ecommerce/preview/cart"
              className="relative flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken"
            >
              <ShoppingBag className="size-4.5" aria-hidden="true" />
              <span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-accent font-mono text-[10px] text-accent-ink">
                {bagCount}
              </span>
              <span className="sr-only">Bag, {bagCount} items</span>
            </Link>
          </div>
        </div>

        <div
          id="commerce-search-panel"
          hidden={!searchOpen}
          className="border-t border-line-subtle px-4 py-3 xl:hidden"
        >
          <SearchField placeholder="Search the shop" aria-label="Search the shop" />
        </div>

        <div
          id="commerce-nav-panel"
          hidden={!open}
          className="border-t border-line-subtle lg:hidden"
        >
          <nav aria-label="Shop categories, mobile" className="px-4 py-3">
            <ul className="flex flex-col gap-0.5">
              {shopLinks.map((link) => (
                <li key={link.href}>
                  <Link
                    href={link.href}
                    className="flex min-h-11 items-center rounded-md px-3 text-sm text-ink hover:bg-surface-sunken"
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
              <li>
                <Link
                  href="/starters/ecommerce/preview/shop"
                  className="flex min-h-11 items-center gap-2 rounded-md px-3 text-sm font-medium text-accent hover:bg-accent-soft"
                >
                  Sale
                  <Badge size="sm" tone="danger">
                    −30%
                  </Badge>
                </Link>
              </li>
            </ul>
          </nav>
        </div>
      </div>
    </div>
  )
}

components/blocks/sections/product/feature-grid.tsx

import Link from 'next/link'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'

/**
 * Product grid
 *
 * A storefront grid with reserved image areas, price comparison and stock
 * state. Out-of-stock items stay visible and are marked, rather than being
 * removed — hiding them is how a catalogue loses the customer who came for that
 * exact thing.
 */
export default function ProductFeatureGrid() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              New this season
            </h2>
            <p className="mt-2 text-sm text-ink-muted">Eight pieces, all fictional.</p>
          </div>
          <Link
            href="/starters/ecommerce/preview/shop"
            className="text-sm font-semibold text-accent underline underline-offset-4"
          >
            View everything
          </Link>
        </div>

        <ul className="mt-10 grid gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-4">
          {products.slice(0, 8).map((product) => (
            <li key={product.slug}>
              <Link
                href={`/starters/ecommerce/preview/shop/${product.slug}`}
                className="group block"
              >
                <div className="relative">
                  <div
                    className="grid-paper aspect-[4/5] w-full rounded-lg border border-line bg-surface-sunken"
                    aria-hidden="true"
                  />
                  {product.badge ? (
                    <Badge tone="accent" size="sm" className="absolute top-3 left-3">
                      {product.badge}
                    </Badge>
                  ) : null}
                  {!product.inStock ? (
                    <span className="absolute inset-0 flex items-center justify-center rounded-lg bg-canvas/70">
                      <Badge appearance="solid" tone="neutral">
                        Out of stock
                      </Badge>
                    </span>
                  ) : null}
                </div>
                <h3 className="mt-3 text-sm font-medium text-ink group-hover:text-accent">
                  {product.name}
                </h3>
                <p className="mt-0.5 text-xs text-ink-muted">{product.material}</p>
                <p className="mt-1.5 flex items-baseline gap-2">
                  <span className="font-mono text-sm font-semibold text-ink-strong tabular-nums">
                    {formatCurrency(product.priceCents)}
                  </span>
                  {product.compareAtCents ? (
                    <span className="font-mono text-xs text-ink-subtle line-through tabular-nums">
                      {formatCurrency(product.compareAtCents)}
                    </span>
                  ) : null}
                </p>
              </Link>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

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 hero is short and the category rail sits immediately beneath it.

  • The reassurance strip belongs in the footer, where hesitation happens — not in the hero.
  • Keep out-of-stock products visible and marked rather than removing them.

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.

  • Announcement bar and category rail
  • Short inverted hero
  • Product grid with stock states
  • Reassurance footer

Accessibility

Bag count
The cart count is part of the link’s accessible name, not a coloured circle.
Stock state
Out-of-stock is stated in a badge rather than implied by reduced opacity.

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.