Ecommerce navbar
Announcement bar, category rail, search, account and a bag whose count is in the accessible name.
Live preview
Source
This exact file renders the preview above.
'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/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/ui/search-field.tsx
'use client'
import { useRef, useState, type InputHTMLAttributes } from 'react'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'
/**
* SearchField
*
* `type="search"` with the browser's own clear button suppressed and replaced
* by one that matches the library — mainly so it is reachable by keyboard and
* carries a real accessible name, which the native one does not in every
* engine.
*
* Clearing returns focus to the input; otherwise keyboard users land at the
* top of the document after emptying a query.
*/
export interface SearchFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
onValueChange?: (value: string) => void
fieldSize?: 'sm' | 'md' | 'lg'
/** Shown on the trailing edge when empty — typically a ⌘K hint. */
shortcutHint?: string
}
const heights = {
sm: 'h-control-sm text-xs',
md: 'h-control text-sm',
lg: 'h-control-lg text-base',
} as const
export function SearchField({
onValueChange,
fieldSize = 'md',
shortcutHint,
className,
defaultValue = '',
value: controlledValue,
placeholder = 'Search',
...props
}: SearchFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [uncontrolled, setUncontrolled] = useState(String(defaultValue))
const value = controlledValue === undefined ? uncontrolled : String(controlledValue)
const update = (next: string) => {
if (controlledValue === undefined) setUncontrolled(next)
onValueChange?.(next)
}
return (
<div className={cn('relative flex items-center', className)}>
<Search
className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
aria-hidden="true"
/>
<input
ref={inputRef}
type="search"
value={value}
placeholder={placeholder}
onChange={(event) => update(event.target.value)}
className={cn(
controlSurface,
heights[fieldSize],
'pr-9 pl-9',
'[&::-webkit-search-cancel-button]:appearance-none',
)}
{...props}
/>
{value ? (
<button
type="button"
onClick={() => {
update('')
inputRef.current?.focus()
}}
className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle hover:bg-surface-sunken hover:text-ink"
>
<X className="size-3.5" aria-hidden="true" />
<span className="sr-only">Clear search</span>
</button>
) : shortcutHint ? (
<kbd
className="pointer-events-none absolute right-2.5 rounded-sm border border-line bg-surface-sunken px-1.5 py-0.5 font-mono text-2xs text-ink-subtle"
aria-hidden="true"
>
{shortcutHint}
</kbd>
) : null}
</div>
)
}
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
Three jobs at once — browse, search, checkout. The announcement bar is part of the pattern rather than a separate block, because its height affects the sticky offset of everything beneath it.
- The bag count is spoken as part of the link name ("Bag, 3 items"), not left to a coloured circle.
- Search collapses to an icon below `xl` and expands into its own row rather than shrinking the category rail.
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
- Category rail
- Collapsible mobile search
- Bag with count
Accessibility
- Counts in names
- Numeric badges are duplicated into the accessible name so they are never visual-only.
- Disclosure pair
- Menu and search are independent disclosures, each with its own `aria-expanded` and panel.
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 navigationEcommerce 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 footer
A four-point reassurance strip above shop, help and account columns.
starter3 variantsProduct grid
A storefront grid with reserved image areas, price comparison and stock state.
starterFeatured3 variants