Skip to content

Ecommerce navbar

Announcement bar, category rail, search, account and a bag whose count is in the accessible name.

Navbarsintermediatenavbarcommercecartsearchannouncement

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.

'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.