Make search inputs accessible
Search functionality is accessible with proper input type, label, role, and autocomplete suggestions.
- 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
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
| Requirement | Implementation |
|---|---|
| Form role | role="search" on form element |
| Input type | type="search" |
| Label | Visible label or aria-label |
| Autocomplete | Keyboard navigable suggestions |
| Results announcement | Live 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
- Tab to search input and submit with Enter
- Test autocomplete with keyboard (Arrow keys, Enter, Escape)
- Verify screen reader announces suggestion count
- Check results count is announced
- Verify clear button is keyboard accessible
- Test with screen reader (announces role="search")
- Check focus management after form submission
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.