Foundations

Typeahead

Moving to an item by typing the beginning of its name.

Markdown

Typeahead moves focus to an item when people type the beginning of its name. It makes long, ordered collections quick to traverse without a pointer.

Typed queries

Type the start of a name; repeat a letter to cycle; pause to begin again. Accents and capitals are ignored.

Focus the list and type a city.
Amsterdam
Auckland
Berlin
Bogotá
Kyoto
Lisbon
London
New Delhi
New York
Oslo
São Paulo
Zürich
Code
"use client";

import { matchTypeahead, TYPEAHEAD_TIMEOUT, useTypeahead } from "@publictheta/ui/primitives/typeahead.ts";
import { useCallback, useRef, useState } from "react";

const CITIES = [
  "Amsterdam",
  "Auckland",
  "Berlin",
  "Bogotá",
  "Kyoto",
  "Lisbon",
  "London",
  "New Delhi",
  "New York",
  "Oslo",
  "São Paulo",
  "Zürich",
];
const ITEMS = CITIES.map((city) => ({ key: city, text: city }));

export default function TypeaheadQuery() {
  const [focused, setFocused] = useState("Amsterdam");
  const [query, setQuery] = useState<{ text: string; at: number } | null>(null);
  const refs = useRef(new Map<string, HTMLDivElement>());

  const onKeyDown = useTypeahead(
    useCallback(
      (text: string) => {
        setQuery({ text, at: performance.now() });
        const match = matchTypeahead(ITEMS, text, focused);
        if (match !== null) {
          refs.current.get(match)?.focus({ preventScroll: true });
          refs.current.get(match)?.scrollIntoView({ block: "nearest" });
        }
      },
      [focused],
    ),
  );

  return (
    <div className="flex w-full flex-col items-center gap-5">
      <div className="flex h-8 items-center gap-3" aria-live="polite">
        {query === null ? (
          <span className="text-sm text-fg-muted">Focus the list and type a city.</span>
        ) : (
          <>
            <span className="rounded-md bg-surface-muted px-2 py-1 font-mono text-sm whitespace-pre text-fg-strong">
              {query.text}
            </span>
            {/* Drains over the typeahead timeout; a new character restarts it. */}
            <span className="h-1 w-24 overflow-hidden rounded-full bg-surface-muted">
              <span
                key={query.at}
                className="block h-full origin-left bg-accent"
                style={{ animation: `drain ${TYPEAHEAD_TIMEOUT}ms linear forwards` }}
                onAnimationEnd={() => setQuery(null)}
              />
            </span>
          </>
        )}
      </div>
      <div
        role="listbox"
        aria-label="Cities"
        tabIndex={-1}
        onKeyDown={(event) => {
          if (onKeyDown(event)) event.preventDefault();
        }}
        className="max-h-64 w-56 overflow-auto rounded-lg border border-border-muted bg-surface p-1"
      >
        {CITIES.map((city) => (
          <div
            key={city}
            ref={(node) => {
              if (node) refs.current.set(city, node);
              else refs.current.delete(city);
            }}
            role="option"
            aria-selected={city === focused}
            tabIndex={city === focused ? 0 : -1}
            onFocus={() => setFocused(city)}
            className="rounded-md px-2 py-1.5 text-sm text-fg outline-none focus:bg-selected focus:text-on-selected"
          >
            {city}
          </div>
        ))}
      </div>
    </div>
  );
}

Best practices

Match the beginning of each name, ignoring case and accents. People type what they read, without capitals or diacritics: typing "zu" should find "Zürich".

Treat quick typing as one query, and a pause as the start of another. Characters typed in quick succession accumulate; after about a second without typing, the next character begins a new search.

Cycle on a repeated character. Pressing the same letter again moves to the next item that begins with it, so a single key steps through a section of the collection.

End the query when focus leaves. A query belongs to one visit to the collection. What is typed on returning starts a new one, however soon.

Read no more than the search needs. In a long collection, look at each item's name only as the search reaches it, and stop at the match.

Let a query contain spaces. Once a query has begun, Space belongs to it, so names of several words can be typed. Before a query begins, Space keeps its usual meaning.

Match the name, not the decoration. When an item's visible label includes an icon, a count or a status, match against the name alone.

Order the collection so typeahead is predictable. In an alphabetical or otherwise familiar order, typing lands where people expect. In an arbitrary order it remains a shortcut, but cannot be relied on.

Behaviour

A character finds the next item starting with it, after the current one
expect(matchTypeahead(ITEMS, "a", "Apple")).toBe("Apricot");
The search comes round past the end
expect(matchTypeahead(ITEMS, "a", "Banana")).toBe("Apple");
A longer query may match the current item
expect(matchTypeahead(ITEMS, "app", "Apple")).toBe("Apple");
A repeated character cycles like a single one
expect(matchTypeahead(ITEMS, "aa", "Apple")).toBe("Apricot");
Case and accents are ignored
expect(matchTypeahead(ITEMS, "ec", null)).toBe("Éclair");
expect(matchTypeahead(ITEMS, "E", "Éclair")).toBe("eel");
No match is no match
expect(matchTypeahead(ITEMS, "z", "Apple")).toBeNull();
Among ten thousand items, a query reads the text of only those up to its match
let read = 0;
const items = Array.from({ length: 10_000 }, (_, index) => ({
  key: String(index),
  get text() {
    read += 1;
    return index === 5002 ? "Quince" : `Option ${index}`;
  },
}));
expect(matchTypeahead(items, "q", "5000")).toBe("5002");
expect(read).toBe(2);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/typeahead.json
Source

src/primitives/typeahead.ts

import { useCallback, useEffect, useMemo, useRef } from "react";
import type { KeyboardEvent } from "react";

/** Long enough to type a word, short enough that a pause starts a new search. */
export const TYPEAHEAD_TIMEOUT = 1000;

export interface TypeaheadItem {
  readonly key: string;
  readonly text: string;
}

const collator = new Intl.Collator(undefined, { sensitivity: "base", usage: "search" });

function startsWith(text: string, query: string): boolean {
  const head = Array.from(text.normalize("NFC")).slice(0, Array.from(query).length).join("");
  return collator.compare(head, query) === 0;
}

/**
 * The item a typed query lands on, ignoring case and accents. A single character
 * — or the same character repeated — moves on to the next item starting with it,
 * so pressing a letter again cycles; a longer query may stay where it is, so
 * typing a word does not skip past the match it has already found.
 *
 * An item's `text` is read only as the search reaches it, and the search stops
 * at its match: `text` may be a getter over something costly to read.
 */
export function matchTypeahead(
  items: readonly TypeaheadItem[],
  query: string,
  currentKey: string | null,
): string | null {
  const characters = Array.from(query.normalize("NFC"));
  if (characters.length === 0) return null;
  const repeated = characters.every((c) => c === characters[0]);
  const search = repeated ? characters[0]! : characters.join("");

  const current = items.findIndex((item) => item.key === currentKey);
  const start = current + (repeated ? 1 : 0);
  for (let offset = 0; offset < items.length; offset += 1) {
    const item = items[(Math.max(start, 0) + offset) % items.length]!;
    if (startsWith(item.text, search)) return item.key;
  }
  return null;
}

export interface TypeaheadHandler {
  (event: KeyboardEvent): boolean;
  readonly reset: () => void;
}

const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta", "CapsLock", "AltGraph", "Fn"]);

/**
 * Accumulates typed characters into a query. Returns a key handler that reports
 * whether it consumed the key. Space counts as a character once a query has
 * started, so "New York" can be typed; before that it is left to the component.
 * Any other key — an arrow, Home — ends the query, so the next character starts
 * a new one from where focus has moved. `reset` ends it from outside: when focus
 * leaves, what is typed on return is a new query.
 */
export function useTypeahead(onQuery: (query: string) => void): TypeaheadHandler {
  const query = useRef("");
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => () => clearTimeout(timer.current), []);

  const reset = useCallback(() => {
    clearTimeout(timer.current);
    query.current = "";
  }, []);

  const handle = useCallback(
    (event: KeyboardEvent): boolean => {
      if (event.nativeEvent.isComposing || MODIFIERS.has(event.key)) return false;
      if (Array.from(event.key).length !== 1 || event.ctrlKey || event.metaKey || event.altKey) {
        reset();
        return false;
      }
      if (event.key === " " && query.current === "") return false;

      query.current += event.key;
      clearTimeout(timer.current);
      timer.current = setTimeout(reset, TYPEAHEAD_TIMEOUT);
      onQuery(query.current);
      return true;
    },
    [onQuery, reset],
  );

  // Both are called by events, never as this renders.
  // oxlint-disable-next-line react/refs
  return useMemo(() => Object.assign((event: KeyboardEvent) => handle(event), { reset }), [handle, reset]);
}