Ecommerce landing page
Product first, with everything above the grid existing only to reach it faster.
Live preview
Open the full pageSource
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.
Related
All patternsEcommerce starter
A storefront with ten product routes, five category routes, a working cart with quantity controls and the multi-step checkout from the form catalogue.
advancedFeatured4 variantsEcommerce navbar
Announcement bar, category rail, search, account and a bag whose count is in the accessible name.
intermediate4 variantsProduct grid
A storefront grid with reserved image areas, price comparison and stock state.
starterFeatured3 variants