Search-led hero
A large search field with seeded suggestions and live, announced results.
Live preview
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.
Related
All navigationSearch field
A search input with a keyboard-reachable clear button and an optional shortcut hint.
starter4 variantsCommand menu
A modal command palette: fuzzy-ranked results, grouped output, active-descendant browsing and a keyboard-first footer.
advancedFeatured4 variantsNo search results
The failed-query state, with the query echoed and the active filters shown.
starterFeatured3 variants