Skip to content

Search-led hero

A large search field with seeded suggestions and live, announced results.

Headersintermediateherosearchcataloguehelp-centre

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 { useMemo, useState } from 'react'
import Link from 'next/link'
import { SearchField } from '@/components/ui/search-field'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { SearchX } from 'lucide-react'

/**
 * Search-led header
 *
 * For catalogues and help centres, where the fastest path is a query rather
 * than a menu. The suggestion chips are not decoration — they seed the query
 * for people who do not yet know the vocabulary of the catalogue.
 *
 * Results announce their count in a polite live region, so a screen-reader
 * user learns the query worked without moving focus.
 */
const catalogue = [
  { label: 'Button', href: '/components/button', group: 'Components' },
  { label: 'Dialog', href: '/components/dialog', group: 'Components' },
  { label: 'Table', href: '/components/table', group: 'Components' },
  { label: 'Command menu', href: '/components/command-menu', group: 'Components' },
  { label: 'Design tokens', href: '/docs/design-tokens', group: 'Documentation' },
  { label: 'Accessibility', href: '/docs/accessibility', group: 'Documentation' },
]

const suggestions = ['button', 'dialog', 'tokens']

export default function SearchLedHeader() {
  const [query, setQuery] = useState('')

  const results = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return []
    return catalogue.filter((entry) => entry.label.toLowerCase().includes(q))
  }, [query])

  return (
    <header className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow" className="text-center">
        <h1 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl">
          What are you looking for?
        </h1>
        <p className="mx-auto mt-3 max-w-lg text-md text-ink-muted">
          Search the whole catalogue — components, sections, forms, patterns and starters.
        </p>

        <div className="mx-auto mt-7 max-w-xl">
          <SearchField
            fieldSize="lg"
            value={query}
            onValueChange={setQuery}
            placeholder="Search Foundry"
            aria-label="Search Foundry"
          />
        </div>

        <ul className="mt-4 flex flex-wrap items-center justify-center gap-2">
          <li className="text-xs text-ink-subtle">Try:</li>
          {suggestions.map((suggestion) => (
            <li key={suggestion}>
              <button
                type="button"
                onClick={() => setQuery(suggestion)}
                className="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted transition-colors hover:border-accent hover:text-accent"
              >
                {suggestion}
              </button>
            </li>
          ))}
        </ul>

        <p aria-live="polite" className="sr-only">
          {query.trim() ? `${results.length} results for ${query.trim()}` : ''}
        </p>

        {query.trim() ? (
          <div className="mx-auto mt-8 max-w-xl text-left">
            {results.length > 0 ? (
              <ul className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
                {results.map((entry) => (
                  <li key={entry.href}>
                    <Link
                      href={entry.href}
                      className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-surface-sunken"
                    >
                      <span className="text-sm font-medium text-ink">{entry.label}</span>
                      <span className="label-caps text-ink-subtle">{entry.group}</span>
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <EmptyState
                size="sm"
                icon={<SearchX className="size-5" />}
                title={`No matches for “${query.trim()}”`}
                description="Try a shorter term, or pick one of the suggestions above."
              />
            )}
          </div>
        ) : null}
      </Container>
    </header>
  )
}

components/blocks/headers/search-led.tsx

'use client'

import { useMemo, useState } from 'react'
import Link from 'next/link'
import { SearchField } from '@/components/ui/search-field'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { SearchX } from 'lucide-react'

/**
 * Search-led header
 *
 * For catalogues and help centres, where the fastest path is a query rather
 * than a menu. The suggestion chips are not decoration — they seed the query
 * for people who do not yet know the vocabulary of the catalogue.
 *
 * Results announce their count in a polite live region, so a screen-reader
 * user learns the query worked without moving focus.
 */
const catalogue = [
  { label: 'Button', href: '/components/button', group: 'Components' },
  { label: 'Dialog', href: '/components/dialog', group: 'Components' },
  { label: 'Table', href: '/components/table', group: 'Components' },
  { label: 'Command menu', href: '/components/command-menu', group: 'Components' },
  { label: 'Design tokens', href: '/docs/design-tokens', group: 'Documentation' },
  { label: 'Accessibility', href: '/docs/accessibility', group: 'Documentation' },
]

const suggestions = ['button', 'dialog', 'tokens']

export default function SearchLedHeader() {
  const [query, setQuery] = useState('')

  const results = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return []
    return catalogue.filter((entry) => entry.label.toLowerCase().includes(q))
  }, [query])

  return (
    <header className="border-b border-line bg-surface-sunken py-section">
      <Container size="narrow" className="text-center">
        <h1 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl">
          What are you looking for?
        </h1>
        <p className="mx-auto mt-3 max-w-lg text-md text-ink-muted">
          Search the whole catalogue — components, sections, forms, patterns and starters.
        </p>

        <div className="mx-auto mt-7 max-w-xl">
          <SearchField
            fieldSize="lg"
            value={query}
            onValueChange={setQuery}
            placeholder="Search Foundry"
            aria-label="Search Foundry"
          />
        </div>

        <ul className="mt-4 flex flex-wrap items-center justify-center gap-2">
          <li className="text-xs text-ink-subtle">Try:</li>
          {suggestions.map((suggestion) => (
            <li key={suggestion}>
              <button
                type="button"
                onClick={() => setQuery(suggestion)}
                className="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted transition-colors hover:border-accent hover:text-accent"
              >
                {suggestion}
              </button>
            </li>
          ))}
        </ul>

        <p aria-live="polite" className="sr-only">
          {query.trim() ? `${results.length} results for ${query.trim()}` : ''}
        </p>

        {query.trim() ? (
          <div className="mx-auto mt-8 max-w-xl text-left">
            {results.length > 0 ? (
              <ul className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
                {results.map((entry) => (
                  <li key={entry.href}>
                    <Link
                      href={entry.href}
                      className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-surface-sunken"
                    >
                      <span className="text-sm font-medium text-ink">{entry.label}</span>
                      <span className="label-caps text-ink-subtle">{entry.group}</span>
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <EmptyState
                size="sm"
                icon={<SearchX className="size-5" />}
                title={`No matches for “${query.trim()}”`}
                description="Try a shorter term, or pick one of the suggestions above."
              />
            )}
          </div>
        ) : null}
      </Container>
    </header>
  )
}

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/empty-state.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * EmptyState
 *
 * An empty state is a piece of product writing more than a piece of UI, so the
 * component enforces the three parts that make one useful: what is missing,
 * why it might be missing, and the single most likely next action.
 */
export interface EmptyStateProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  secondaryAction?: ReactNode
  /** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
  appearance?: 'panel' | 'bare'
  size?: 'sm' | 'md' | 'lg'
  className?: string
}

export function EmptyState({
  icon,
  title,
  description,
  action,
  secondaryAction,
  appearance = 'panel',
  size = 'md',
  className,
}: EmptyStateProps) {
  const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]

  return (
    <div
      className={cn(
        'flex flex-col items-center px-6 text-center',
        padding,
        appearance === 'panel' &&
          'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
        className,
      )}
    >
      {icon ? (
        <div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
          {icon}
        </div>
      ) : null}
      <p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
      {description ? (
        <p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
      ) : null}
      {(action || secondaryAction) && (
        <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
          {action}
          {secondaryAction}
        </div>
      )}
    </div>
  )
}

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

Usage

For catalogues and help centres, where the fastest path is a query. The suggestion chips are not decoration — they seed the query for people who do not yet know the vocabulary of the catalogue.

  • Announce the result count politely so a screen-reader user learns the query worked without moving focus.
  • Echo the query in the empty state so a typo is visible.

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.

  • Large search field
  • Suggestion chips
  • Live results
  • Empty state

Accessibility

Live region
A visually hidden `aria-live="polite"` region reports the result count.
Field naming
The search input has an explicit accessible name, not just a placeholder.

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.