Keyboard-friendly dropdowns

Handle arrows, Enter and Escape, and expose combobox ARIA.

From frontend-patterns, “Accessibility Patterns: Keyboard Navigation”.

Pick Solana without a mouse

Tab to each dropdown, then use arrows and Enter.

Mouse-only divBad
Coin

Nothing picked

    tabindex="0"onClick
    Keyboard handlerGood
    Coin

    Nothing picked

      onKeyDownrole="combobox"
      function Dropdown({ options, onSelect }) {
        const [isOpen, setIsOpen] = useState(false)
        const [activeIndex, setActiveIndex] = useState(0)
      
        const handleKeyDown = (e) => {
          if (!isOpen) {
            if (e.key === 'ArrowDown' || e.key === 'Enter') { e.preventDefault(); setIsOpen(true) }
            return
          }
          switch (e.key) {
            case 'ArrowDown':
              e.preventDefault()               // or the page scrolls
              setActiveIndex(i => Math.min(i + 1, options.length - 1))
              break
            case 'ArrowUp':
              e.preventDefault()
              setActiveIndex(i => Math.max(i - 1, 0))
              break
            case 'Enter':
              e.preventDefault()
              onSelect(options[activeIndex])
              setIsOpen(false)
              break
            case 'Escape':
              setIsOpen(false)
              break
          }
        }
      
        return (
          <div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox"
               tabIndex={0} onKeyDown={handleKeyDown}>
            {/* trigger + listbox */}
          </div>
        )
      }

      Clamp the index to the list

      Press Hold ArrowDown, then Enter.

      UnclampedBad
        activeIndex0of 0…5

        setActiveIndex(i => i + 1)
        ClampedGood
          activeIndex0of 0…5

          Math.min(i + 1, options.length - 1)
          setActiveIndex(i => i + 1)   // runs past the end
          
          setActiveIndex(i => Math.min(i + 1, options.length - 1))
          setActiveIndex(i => Math.max(i - 1, 0))

          What a screen reader hears

          Open, move and close each with the keyboard.

          No roles, no stateBad
          Coin

          Heard

          Combobox + listboxGood
          Coin

          Heard

          <div role="combobox" tabIndex={0}
               aria-expanded={isOpen}
               aria-haspopup="listbox"
               aria-controls="coin-list"
               aria-activedescendant={isOpen ? `coin-${activeIndex}` : undefined}
               onKeyDown={handleKeyDown}>
            {selected ?? 'Choose a coin'}
          </div>
          <ul id="coin-list" role="listbox" hidden={!isOpen}>
            {options.map((o, i) => (
              <li key={o} id={`coin-${i}`} role="option" aria-selected={o === selected}>{o}</li>
            ))}
          </ul>

          Why it matters, pros and cons

          Why it matters

          Mouse-only dropdowns lock out keyboard users. ARIA says what it is and its state.

          Pros

          • One handler serves keyboard and screen readers.
          • No page jump while moving.
          • Enter never selects undefined.

          Cons

          • A native <select> does this for free.
          • Minimum only: add Home/End, type-ahead, scroll-into-view.
          • Command menus often wrap instead of clamping.