LocalityAPI
Get API Key

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 attributesrole="combobox", aria-expanded, aria-activedescendant for 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.