Typeahead

Add typeahead to a list or menu so typing a label focuses its item, with prefix matching, repeated-character cycling and accent handling.

Create a typeahead handler to focus items by typing their labels. In this list, S focuses Spanish and W immediately after it focuses Swedish. After a 500 ms pause, the next character starts a new prefix.

Live · languages
  • Arabic
  • Bengali
  • Chinese
  • Czech
  • Danish
  • Dutch
  • English
  • Finnish
  • French
  • German
  • Greek
  • Hindi
  • Hungarian
  • Italian
  • Japanese
  • Korean
  • Norwegian
  • Polish
  • Portuguese
  • Romanian
  • Russian
  • Spanish
  • Swedish
  • Turkish
<ul id="languages">
  <li data-keyrove-item tabindex="0">Arabic</li>
  <li data-keyrove-item tabindex="0">Bengali</li>
  <li data-keyrove-item tabindex="0">Chinese</li>
  <!-- … -->
  <li data-keyrove-item tabindex="0">Turkish</li>
</ul>

Call keyRove first so navigation bindings take precedence. It returns null for an unhandled key, letting typeahead process it. For example, a KeyJ navigation binding moves focus instead of adding J to the prefix.

Why a factory

The returned handler stores the prefix and the time of the last character. Create it once per listener, outside the keydown callback. It checks the time on each press, so there is no timer to clean up. Set resetMs to change the pause that clears the prefix:

const typeahead = createTypeahead({ resetMs: 800 });

Cycling repeated characters

Prefix matching is the default: pressing S twice looks for a label starting with ss, and every prefix matches from the top of the list. A menu can instead cycle through the items sharing one initial character, the way a native <select> does:

const typeahead = createTypeahead({ matchMode: 'cycle' });

With that mode, a single character moves focus to the next item after the focused one that begins with it, wrapping after the last. Pressing it again moves on rather than growing the buffer, however slowly it is pressed, so repeated S presses alternate between Spanish and Swedish. A different character typed before the reset still refines the prefix, so S W matches Swedish. The one thing the mode gives up is a label that opens with a doubled letter: A A cycles the A items and never looks for "aa".

What counts as typing

Bindings match the physical key, e.code. Typeahead reads e.key, the character the press produced in the user's layout, so "é" and "ß" work where the keyboard has them. A press joins the buffer when it is typing and nothing else:

  • a single character, with none of Ctrl, Alt or Meta held. Shift is allowed; it is how capitals are typed, and matching ignores case anyway;
  • not inside an editable target, so a field inside an item keeps its letters;
  • a space only once the buffer holds a character, so the spacebar keeps scrolling the page and activating buttons while "do n" still reaches Do not disturb below.

Matching is by prefix. A character that matches nothing is left to the browser but still joins the buffer, so a mistyped prefix goes quiet until the reset clears it rather than jumping somewhere unexpected. Items carrying data-keyrove-skip or disabled are never matched.

When the text is not the label

The label is the item's text, trimmed and with runs of whitespace collapsed. When the text starts with something nobody types, an emoji, an icon's fallback text, a code, data-keyrove-typeahead names the label instead:

Live · presence
  • 🟢 Available
  • 🟡 Away
  • 🔴 Do not disturb
  • ⚫ Appear offline
<ul id="presence">
  <li data-keyrove-item data-keyrove-typeahead="Available" tabindex="0">
    🟢 Available
  </li>
  <li data-keyrove-item data-keyrove-typeahead="Away" tabindex="0">🟡 Away</li>
  <li data-keyrove-item data-keyrove-typeahead="Do not disturb" tabindex="0">
    🔴 Do not disturb
  </li>
  <li data-keyrove-item data-keyrove-typeahead="Appear offline" tabindex="0">
    ⚫ Appear offline
  </li>
</ul>

A is Available, A W is Away, D is Do not disturb. The attribute is the whole label rather than a prefix added to the text, and an empty one falls back to the text, so a template can set it conditionally.

Accented labels

Accents are ignored on both sides: E reaches Émilie, A reaches Ángel, and on a keyboard that can type it, É reaches a plain Emilie too. Each letter is compared with its marks taken off, whether the label comes from the text, the attribute or a label function, so no label needs folding by hand. A letter that is not a base letter plus a mark, such as ø, ł or ß, is matched as itself.

Where an accent is what tells two items apart, turn the folding off:

const typeahead = createTypeahead({ foldDiacritics: false });

What it reports

The handler returns null when it left the key alone and { action: 'typeahead', from, to } when it consumed it, with to: null when the match is already focused or cannot receive focus. onMove fires only after a successful focus move.

Type "swez" quickly to see each result in the readout:

  • S and W are indigo: focus moves to Spanish, then Swedish.
  • E is amber, typeahead · moved nothing: "swe" still matches the focused item, so to is null.
  • Z is gray, left to the browser: "swez" matches nothing, so the handler returns null.

The unmatched character stays in the buffer. Pause for 500 ms to start again.

Both handlers can feed one follow-focus callback, a preview pane for one:

const onMove = ({ to }) => showPreview(to);

const typeahead = createTypeahead({ onMove });

list.addEventListener('keydown', (e) => keyRove(e, { onMove }) || typeahead(e));

The roving tab stop moves with the match, as it does for an arrow move.

Several groups, one handler

The buffer clears when the next typing key resolves to a different root. One handler can therefore serve several groups under a delegated listener without sharing prefixes between them. See several groups, one listener.

Search documentation

↑ ↓ to selectEnter to openEsc to close