Skip to content

Search field

A search input with a keyboard-reachable clear button and an optional shortcut hint.

Formsstartersearchfilterqueryclear

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { useMemo, useState } from 'react'
import { EmptyState } from '@/components/ui/empty-state'
import { SearchField } from '@/components/ui/search-field'
import { SearchX } from 'lucide-react'
import { DemoColumn, DemoStage } from './_kit'

const rows = [
  'Button — actions',
  'Combobox — forms',
  'Command menu — navigation',
  'Dialog — overlay',
  'Metric — data',
  'Toast — feedback',
]

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

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

  return (
    <DemoStage>
      <DemoColumn width="lg">
        <SearchField
          value={query}
          onValueChange={setQuery}
          placeholder="Filter components"
          shortcutHint="⌘K"
          aria-label="Filter components"
        />

        {results.length > 0 ? (
          <ul className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
            {results.map((row) => (
              <li key={row} className="px-3 py-2 text-sm text-ink">
                {row}
              </li>
            ))}
          </ul>
        ) : (
          <EmptyState
            size="sm"
            icon={<SearchX className="size-5" />}
            title={`No matches for “${query.trim()}”`}
            description="Try a shorter term, or search by category instead."
          />
        )}

        <SearchField fieldSize="sm" placeholder="Small" aria-label="Small search" />
        <SearchField fieldSize="lg" placeholder="Large" aria-label="Large search" />
        <SearchField disabled placeholder="Search unavailable" aria-label="Disabled search" />
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/search-field.tsx

'use client'

import { useMemo, useState } from 'react'
import { EmptyState } from '@/components/ui/empty-state'
import { SearchField } from '@/components/ui/search-field'
import { SearchX } from 'lucide-react'
import { DemoColumn, DemoStage } from './_kit'

const rows = [
  'Button — actions',
  'Combobox — forms',
  'Command menu — navigation',
  'Dialog — overlay',
  'Metric — data',
  'Toast — feedback',
]

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

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

  return (
    <DemoStage>
      <DemoColumn width="lg">
        <SearchField
          value={query}
          onValueChange={setQuery}
          placeholder="Filter components"
          shortcutHint="⌘K"
          aria-label="Filter components"
        />

        {results.length > 0 ? (
          <ul className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
            {results.map((row) => (
              <li key={row} className="px-3 py-2 text-sm text-ink">
                {row}
              </li>
            ))}
          </ul>
        ) : (
          <EmptyState
            size="sm"
            icon={<SearchX className="size-5" />}
            title={`No matches for “${query.trim()}”`}
            description="Try a shorter term, or search by category instead."
          />
        )}

        <SearchField fieldSize="sm" placeholder="Small" aria-label="Small search" />
        <SearchField fieldSize="lg" placeholder="Large" aria-label="Large search" />
        <SearchField disabled placeholder="Search unavailable" aria-label="Disabled search" />
      </DemoColumn>
    </DemoStage>
  )
}

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>
  )
}

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

Usage

For filtering a visible list. When the interaction should navigate somewhere instead, use the Command menu — a search box that only filters and a search box that navigates are different components.

  • Clearing returns focus to the input, so a keyboard user is not dropped at the top of the document.
  • The native clear button is suppressed because it is not reachable by keyboard in every engine.
  • Show the shortcut hint only where the shortcut actually exists.

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.

  • With shortcut hint
  • With clear button
  • Three sizes
  • Disabled

Accessibility

Naming
The field needs an `aria-label` or a Field label; a magnifier icon is not a name.
Clear control
The clear button is a real button with a visually hidden label.
Results
Announce result counts in a polite live region — see the Search page for a working example.

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.