City Autocomplete Widget
Build a fast, accessible city search widget with keyboard navigation and mobile support.
Overview
A good autocomplete widget responds instantly (<50ms), supports keyboard navigation, handles edge cases gracefully, and works well on mobile devices. This guide shows how to build one from scratch.
Performance target
The Locality API autocomplete endpoint returns results in under 20ms. Combined with proper debouncing, users should see suggestions within 250ms of typing.
Complete React Component
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
interface City {
id: number
name: string
country: string
type: string
}
interface CityAutocompleteProps {
onSelect: (city: City) => void
country?: string
placeholder?: string
}
export function CityAutocomplete({
onSelect,
country,
placeholder = "Search cities..."
}: CityAutocompleteProps) {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<City[]>([])
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const debounceRef = useRef<NodeJS.Timeout>()
// Fetch suggestions with debouncing
const fetchSuggestions = useCallback(async (q: string) => {
if (q.length < 2) {
setSuggestions([])
setIsOpen(false)
return
}
setLoading(true)
try {
const params = new URLSearchParams({ q, limit: '6' })
if (country) params.set('country', country)
const res = await fetch(`/api/locality/autocomplete?${params}`)
const data = await res.json()
setSuggestions(data.suggestions || [])
setIsOpen(true)
setActiveIndex(-1)
} catch (err) {
console.error('Autocomplete failed:', err)
setSuggestions([])
} finally {
setLoading(false)
}
}, [country])
// Debounced input handler
const handleInput = useCallback((value: string) => {
setQuery(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
fetchSuggestions(value)
}, 200)
}, [fetchSuggestions])
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isOpen || suggestions.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (activeIndex >= 0) {
selectCity(suggestions[activeIndex])
}
break
case 'Escape':
setIsOpen(false)
setActiveIndex(-1)
break
}
}, [isOpen, suggestions, activeIndex])
// Select a city
const selectCity = useCallback((city: City) => {
setQuery(city.name)
setSuggestions([])
setIsOpen(false)
setActiveIndex(-1)
onSelect(city)
}, [onSelect])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
inputRef.current &&
!inputRef.current.contains(e.target as Node) &&
listRef.current &&
!listRef.current.contains(e.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const activeItem = listRef.current.children[activeIndex] as HTMLElement
activeItem?.scrollIntoView({ block: 'nearest' })
}
}, [activeIndex])
return (
<div className="relative w-full">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => suggestions.length > 0 && setIsOpen(true)}
placeholder={placeholder}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 ? `city-option-${activeIndex}` : undefined
}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<svg className="animate-spin h-4 w-4 text-gray-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{isOpen && suggestions.length > 0 && (
<ul
ref={listRef}
role="listbox"
className="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-60 overflow-auto"
>
{suggestions.map((city, index) => (
<li
key={city.id}
id={`city-option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => selectCity(city)}
className={`px-4 py-2 cursor-pointer flex items-center justify-between
${index === activeIndex ? 'bg-blue-50 text-blue-900' : 'hover:bg-gray-50'}
`}
>
<span className="font-medium">{city.name}</span>
<span className="text-sm text-gray-500">
{city.country} • {city.type}
</span>
</li>
))}
</ul>
)}
{isOpen && suggestions.length === 0 && query.length >= 2 && !loading && (
<div className="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg p-4 text-gray-500 text-center">
No cities found
</div>
)}
</div>
)
}Accessibility Features
- ARIA attributes —
role="combobox",aria-expanded,aria-activedescendantfor screen readers. - Keyboard navigation — Arrow keys to navigate, Enter to select, Escape to close.
- Focus management — Focus stays on input, visual highlight follows active option.
- Scroll into view — Active option scrolls into view when using keyboard.
Performance Tips
- Debounce 200ms — Don't fetch on every keystroke; wait for typing to pause.
- Minimum 2 characters — Don't search for single letters; too many results.
- Limit results — Request only 5-6 suggestions; users rarely scroll past that.
- Cancel stale requests — Use AbortController to cancel outdated fetches.