Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Make search inputs accessible

Search functionality is accessible with proper input type, label, role, and autocomplete suggestions.

Utilities
Quick take
Typical fix time 20 min
  • Use type='search' and role='search' on the form
  • Provide visible or screen reader accessible label
  • Make autocomplete suggestions keyboard navigable
  • Announce result counts to screen readers
Why it matters: Search is a primary navigation method—inaccessible search functionality prevents users from finding content efficiently regardless of their abilities.

Rule Details

Accessible search helps all users find content quickly and efficiently.

Code Example

<form role="search" action="/search" method="GET">
  <label for="site-search" class="sr-only">Search</label>
  <input
    type="search"
    id="site-search"
    name="q"
    placeholder="Search..."
    aria-label="Search site content"
    autocomplete="off"
  />
  <button type="submit" aria-label="Submit search">
    <span aria-hidden="true">🔍</span>
    <span class="sr-only">Search</span>
  </button>
</form>

Why It Matters

Search is a primary navigation method—inaccessible search functionality prevents users from finding content efficiently regardless of their abilities.

Accessibility Requirements

RequirementImplementation
Form rolerole="search" on form element
Input typetype="search"
LabelVisible label or aria-label
AutocompleteKeyboard navigable suggestions
Results announcementLive region for result count

React Search Component

import { useState, useRef, useId, useEffect } from 'react'
 
interface SearchProps {
  onSearch: (query: string) => void
  placeholder?: string
  label?: string
}
 
export function Search({
  onSearch,
  placeholder = 'Search...',
  label = 'Search'
}: SearchProps) {
  const [query, setQuery] = useState('')
  const inputId = useId()
  const inputRef = useRef<HTMLInputElement>(null)
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (query.trim()) {
      onSearch(query.trim())
    }
  }
 
  const handleClear = () => {
    setQuery('')
    inputRef.current?.focus()
  }
 
  return (
    <form
      role="search"
      onSubmit={handleSubmit}
      className="search-form"
    >
      <label htmlFor={inputId} className="sr-only">
        {label}
      </label>
 
      <div className="search-input-wrapper">
        <input
          ref={inputRef}
          type="search"
          id={inputId}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={placeholder}
          aria-label={label}
          className="search-input"
        />
 
        {query && (
          <button
            type="button"
            onClick={handleClear}
            aria-label="Clear search"
            className="search-clear"
          >
            ×
          </button>
        )}
 
        <button type="submit" aria-label="Submit search" className="search-submit">
          <SearchIcon aria-hidden="true" />
        </button>
      </div>
    </form>
  )
}
 
function SearchIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" width={20} height={20} {...props}>
      <path
        fill="currentColor"
        d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 001.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 00-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 005.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
      />
    </svg>
  )
}

With Autocomplete Suggestions

interface Suggestion {
  id: string
  text: string
}
 
interface SearchWithAutocompleteProps {
  onSearch: (query: string) => void
  getSuggestions: (query: string) => Promise<Suggestion[]>
}
 
export function SearchWithAutocomplete({
  onSearch,
  getSuggestions
}: SearchWithAutocompleteProps) {
  const [query, setQuery] = useState('')
  const [suggestions, setSuggestions] = useState<Suggestion[]>([])
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(-1)
  const [announcement, setAnnouncement] = useState('')
 
  const inputRef = useRef<HTMLInputElement>(null)
  const listboxRef = useRef<HTMLUListElement>(null)
  const inputId = useId()
  const listboxId = useId()
 
  // Fetch suggestions on query change
  useEffect(() => {
    const fetchSuggestions = async () => {
      if (query.length >= 2) {
        const results = await getSuggestions(query)
        setSuggestions(results)
        setIsOpen(results.length > 0)
        setActiveIndex(-1)
        setAnnouncement(
          results.length > 0
            ? `${results.length} suggestions available`
            : 'No suggestions'
        )
      } else {
        setSuggestions([])
        setIsOpen(false)
      }
    }
 
    const debounce = setTimeout(fetchSuggestions, 300)
    return () => clearTimeout(debounce)
  }, [query, getSuggestions])
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        if (isOpen) {
          setActiveIndex(prev =>
            prev < suggestions.length - 1 ? prev + 1 : 0
          )
        } else if (suggestions.length > 0) {
          setIsOpen(true)
        }
        break
 
      case 'ArrowUp':
        e.preventDefault()
        if (isOpen) {
          setActiveIndex(prev =>
            prev > 0 ? prev - 1 : suggestions.length - 1
          )
        }
        break
 
      case 'Enter':
        if (activeIndex >= 0 && isOpen) {
          e.preventDefault()
          selectSuggestion(suggestions[activeIndex])
        }
        break
 
      case 'Escape':
        setIsOpen(false)
        setActiveIndex(-1)
        break
    }
  }
 
  const selectSuggestion = (suggestion: Suggestion) => {
    setQuery(suggestion.text)
    setIsOpen(false)
    setActiveIndex(-1)
    onSearch(suggestion.text)
  }
 
  return (
    <div className="search-autocomplete">
      <form
        role="search"
        onSubmit={(e) => {
          e.preventDefault()
          onSearch(query)
          setIsOpen(false)
        }}
      >
        <label htmlFor={inputId} className="sr-only">
          Search
        </label>
 
        <input
          ref={inputRef}
          type="search"
          id={inputId}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          onFocus={() => suggestions.length > 0 && setIsOpen(true)}
          onBlur={() => setTimeout(() => setIsOpen(false), 200)}
          placeholder="Search..."
          role="combobox"
          aria-expanded={isOpen}
          aria-controls={listboxId}
          aria-activedescendant={
            activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
          }
          aria-autocomplete="list"
          autoComplete="off"
          className="search-input"
        />
 
        <button type="submit" aria-label="Search" className="search-submit">
          🔍
        </button>
      </form>
 
      {/* Suggestions dropdown */}
      {isOpen && suggestions.length > 0 && (
        <ul
          ref={listboxRef}
          id={listboxId}
          role="listbox"
          aria-label="Search suggestions"
          className="search-suggestions"
        >
          {suggestions.map((suggestion, index) => (
            <li
              key={suggestion.id}
              id={`suggestion-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => selectSuggestion(suggestion)}
              className={`search-suggestion ${
                index === activeIndex ? 'search-suggestion--active' : ''
              }`}
            >
              {suggestion.text}
            </li>
          ))}
        </ul>
      )}
 
      {/* Live region for announcements */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {announcement}
      </div>
    </div>
  )
}

Search Results with Count

interface SearchResultsProps {
  query: string
  results: any[]
  isLoading: boolean
}
 
export function SearchResults({ query, results, isLoading }: SearchResultsProps) {
  return (
    <div className="search-results">
      {/* Status announcement */}
      <div role="status" aria-live="polite" className="search-status">
        {isLoading ? (
          'Searching...'
        ) : (
          `${results.length} results for "${query}"`
        )}
      </div>
 
      {/* Results list */}
      {results.length > 0 && (
        <ul role="list" className="results-list">
          {results.map(result => (
            <li key={result.id}>
              <a href={result.url}>{result.title}</a>
              <p>{result.excerpt}</p>
            </li>
          ))}
        </ul>
      )}
 
      {/* No results */}
      {!isLoading && results.length === 0 && query && (
        <p>No results found for "{query}". Try different keywords.</p>
      )}
    </div>
  )
}

Styling

.search-form {
  display: flex;
  align-items: center;
}
 
.search-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}
 
.search-input {
  width: 100%;
  padding: 0.75rem 2.5rem 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}
 
.search-input:focus {
  outline: none;
  border-color: #0066cc;
  box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
 
.search-clear {
  position: absolute;
  right: 3rem;
  padding: 0.25rem;
  background: none;
  border: none;
  font-size: 1.25rem;
  color: #999;
  cursor: pointer;
}
 
.search-submit {
  padding: 0.75rem;
  background: #0066cc;
  color: #fff;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
}
 
.search-submit:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}
 
/* Autocomplete dropdown */
.search-autocomplete {
  position: relative;
}
 
.search-suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  list-style: none;
  margin: 0.25rem 0 0;
  padding: 0;
  z-index: 100;
}
 
.search-suggestion {
  padding: 0.75rem 1rem;
  cursor: pointer;
}
 
.search-suggestion:hover,
.search-suggestion--active {
  background: #f5f5f5;
}
 
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

Verification

  1. Tab to search input and submit with Enter
  2. Test autocomplete with keyboard (Arrow keys, Enter, Escape)
  3. Verify screen reader announces suggestion count
  4. Check results count is announced
  5. Verify clear button is keyboard accessible
  6. Test with screen reader (announces role="search")
  7. Check focus management after form submission
Avoid Placeholder as Label

Don't rely solely on placeholder text as a label—it disappears when typing. Use a visible label or aria-label in addition to placeholder.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Verify search inputs use type='search', have accessible labels, and use role='search' on the containing form.

Fix

Auto-fix issues

Implement search with type='search', visible or aria-label, form role='search', and accessible autocomplete suggestions.

Explain

Learn more

Explain how accessible search implementations help users find content efficiently regardless of their abilities.

Review

Code review

Review templates, server-rendered HTML, and shared components that output markup related to Make search inputs accessible. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Nu Html Checker
validator.w3.orgTool
Autocomplete Pattern – UX Patterns for Developers

Comprehensive UX pattern guide covering anatomy, accessibility, best practices, and implementation.

uxpatterns.devGuide

Rules that often go hand-in-hand with this one.

Make file uploads accessible

File upload components are accessible with proper labels, file type restrictions, and progress feedback.

HTML
Validate forms accessibly

Forms provide clear validation feedback with accessible error messages and proper ARIA attributes.

HTML
Use semantic input type attributes

Set the correct type attribute on input elements to trigger the right mobile keyboard, enable browser validation, and improve autofill accuracy.

HTML
Provide accessible names for select elements

All `<select>` elements must have an associated label or an accessible name to be correctly identified by screen readers.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385