Plate № 39 · UX primitives · search & selection

A pattern from the gf.cx specimen book

A picker that finds it as you type

Origin · sandbox.gf.cx/miles-vs-cash

"Make it a pattern." — the loyalty-currency benchmark picker earned the mandate: a field where you type the airline you know and land on the currency it earns.

The problem

A plain <select> is the honest default for "pick one of a fixed set" — until the set grows or the reader doesn't know it by its formal name. It can't be searched: the browser matches only the first character you type, and only against the visible option text. A dropdown of a dozen loyalty currencies means scrolling for the one you want, and it's no help at all to the reader who thinks in British Airways or KLM, not Avios or Flying Blue.

The obvious fixes each fail their own way. A long static list doesn't scale past ~15 options before scanning it costs more than it saves. And hand-rolling a combobox per surface — the input, the filtered listbox, arrow-key navigation, the focus/blur race when a click lands as the field is losing focus, the aria wiring, click-outside-to-close — is a hundred lines of fiddly, easy-to-get-subtly-wrong interaction that every surface would re-derive slightly differently. That divergence is exactly the drift the shelf exists to kill: one behaviour, one source, and surfaces opt in by markup plus a single mount() call.

The element, in-page

The live shelf primitive, mounted below over the miles-vs-cash dataset. The value attaches to the currency; the member airlines and banks are search aliases. Type iberia and the list surfaces Avios; klm surfaces Flying Blue; AA surfaces AAdvantage; chase surfaces Ultimate Rewards. Arrow keys move the active row, Enter selects, Esc closes; a click outside closes too.

Live · loyalty-currency picker

Try iberia, klm, AA, or chase — you type the name you know, the picker returns the currency it earns.

The interface

A surface opts in with a small markup skeleton. The shelf positions the caret and lays out the listbox; the host keeps its own field styling on .tf-input:

<div class="tf-combo" data-typeahead>
  <input class="tf-input" role="combobox"
         aria-autocomplete="list" autocomplete="off"
         placeholder="Search…">
  <span class="tf-caret" aria-hidden="true">▾</span>
  <ul class="tf-listbox" role="listbox" hidden></ul>
</div>

Import the two assets, then mount. Because the script is deferred, run mount() after it — inside another defer script, a DOMContentLoaded handler, or a module (interactive JS must be external; Rocket Loader mangles inline <script>):

<link rel="stylesheet" href="https://assets.gf.cx/typeahead/typeahead.css">
<script src="https://assets.gf.cx/typeahead/typeahead.js" defer></script>

// in an external, deferred glue script:
const el = document.querySelector('[data-typeahead]');
Typeahead.mount(el, {
  items: [
    { label: 'Avios',       value: 1.4, aliases: ['British Airways','BA','Iberia'], meta: '1.4¢' },
    { label: 'Flying Blue', value: 1.2, aliases: ['Air France','KLM'],              meta: '1.2¢' },
  ],
  onSelect: (item) => { console.log('picked', item.label, item.value); },
});

The item shape

FieldRequiredMeaning
labelyesdisplay name; matched by search
valueyeswhatever you want back on select (number, string, object)
aliasesnoextra search terms (member airlines, synonyms) — also matched
metanoshort right-aligned badge, e.g. "1.4¢"
subnomuted secondary line; defaults to the aliases joined with ·

The options

OptionDefaultMeaning
onSelect(item)called when a row is chosen
filter(query, items)label/alias substring, case-insensitivecustom filter
format(item)label + + metatext placed in the input on select
renderOption(item)name / sub / meta layoutcustom option innerHTML
openOnFocustrueopen the full list on focus
selectFirstOnFiltertruehighlight the first match while typing

The controller

mount() returns a controller for programmatic control — swap the dataset, choose a row from code, read the last pick, or tear the whole thing down:

const tf = Typeahead.mount(el, { items, onSelect });
tf.selectByValue(1.4);   // choose the item whose value === 1.4 (fires onSelect)
tf.setItems(newItems);   // swap the dataset
tf.getSelected();        // → the last-chosen item (or null)
tf.open(); tf.close();
tf.destroy();            // detach all listeners

Keyboard & a11y

Theming

Retheme via self-scoped --tf-* vars that defer to the host's design tokens — the same model as sortable-table's --st-accent and op-panel's --opp-accent. Any surface that already defines --accent / --line / --card / --ink themes for free; light and dark are handled via prefers-color-scheme. Class names are namespaced (tf-*), so there's no collision risk:

.tf-combo {
  --tf-accent: var(--accent, #2c4a3a);  /* meta badge */
  --tf-bg:     var(--card, #fff);       /* listbox background */
  --tf-line:   var(--line, #e6e6ea);    /* border */
  --tf-ink:    var(--ink, #1a1816);     /* text */
  --tf-sub:    var(--muted, #9a9aa2);   /* sublabel + caret */
}

Where it applies

Reusable elements

Reference

Assets
assets.gf.cx/typeahead/typeahead.js (factory: Typeahead.mount(root, opts)) · assets.gf.cx/typeahead/typeahead.css (self-scoped --tf-* vars, light + dark).
Version
v0.1.0 · 2026-08-16 — extracted + generalised from the sandbox.gf.cx/miles-vs-cash loyalty-currency benchmark picker.
In use / example
sandbox.gf.cx/miles-vs-cash — the canonical worked example (currency value + airline aliases), reproduced in the live demo above.
Companion patterns
One badge, two ways to mean state and A tooltip you can reach into — siblings in the same UX-primitive family, each a shelf behaviour surfaces opt into by markup.
Why it exists
A <select> doesn't search; a long static list doesn't scale past ~15 options; and hand-rolling a combobox per surface (keyboard, focus/blur races, aria, click-outside) is the drift the shelf kills. The picker collapses all of it into markup + one mount().