Plate № 39 · UX primitives · search & selection
A pattern from the gf.cx specimen book
A picker that finds it as you type
"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.
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
| Field | Required | Meaning |
|---|---|---|
label | yes | display name; matched by search |
value | yes | whatever you want back on select (number, string, object) |
aliases | no | extra search terms (member airlines, synonyms) — also matched |
meta | no | short right-aligned badge, e.g. "1.4¢" |
sub | no | muted secondary line; defaults to the aliases joined with · |
The options
| Option | Default | Meaning |
|---|---|---|
onSelect(item) | — | called when a row is chosen |
filter(query, items) | label/alias substring, case-insensitive | custom filter |
format(item) | label + — + meta | text placed in the input on select |
renderOption(item) | name / sub / meta layout | custom option innerHTML |
openOnFocus | true | open the full list on focus |
selectFirstOnFilter | true | highlight 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
- ↓/↑ move the active row (opening the list if closed), Enter selects, Esc closes. Click-outside closes.
- Roles wired: input
role="combobox"+aria-expanded/aria-controls; listboxrole="listbox"; rowsrole="option"+aria-selected. - Options fire on
mousedown(notclick) so the pick lands before blur even as focus leaves the field — the focus/blur race a hand-rolled combobox usually gets wrong.
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
- Any "pick one of many" field where the reader knows a member better than the category — currencies by airline, countries by city, teams by player, a plan by the feature you came for. Aliases turn what the reader types into what the field returns.
- Lists past ~15 options, where a
<select>becomes a scroll and search-as-you-type pays for itself. - Live on sandbox.gf.cx/miles-vs-cash — the loyalty-currency benchmark picker this primitive was extracted from.
Reusable elements
- the
tf-combomarkup skeleton — input + caret + listbox with the aria roles pre-declared. Copy it verbatim; the shelf wires the rest. - the alias-matched item shape —
{label, value, aliases?, meta?, sub?}. The value rides the label so the host gets back exactly what it needs on select; aliases widen the search net without cluttering the display. - the
mount()→ controller contract — one call to attach, a returned handle (setItems/selectByValue/getSelected/open/close/destroy) for programmatic control and clean teardown. - self-scoped
--tf-*theming — the shelf-primitive discipline: namespaced vars that defer to host tokens, so one behaviour looks native on every surface without per-surface CSS.
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 + onemount().