Vanilla UI Kit Autocomplete v1.0.0 · select →

Typeahead, one file.

Free-text autocomplete with async race guards, keyboard browsing, and automatic theming. The input stays yours — suggestions assist, they never constrain. Every card below is live.

Static array

Local filtering, diacritic-insensitive, match highlighted. Try “sao” or “zur”.

new Autocomplete('#city', {
  source: ['Amsterdam', 'São Paulo', 'Zürich', …],
  onSelect: item => use(item.value)
})

Async + loading row

A fake API with 300–1200 ms latency. Type fast: the loading row shows while pending, and answers for superseded queries are discarded — a slow “ap” can never overwrite “apple”.

new Autocomplete('#fruit', {
  source: q => slowApi(q),   // Promise — or fn(q, done)
  onError: e => console.warn(e)
})

Groups

Items with a group render under uppercase headers, exactly like Select.

source: [
  { value: 'js',  label: 'JavaScript', group: 'Dynamic' },
  { value: 'rs',  label: 'Rust',       group: 'Compiled' },
  …
]

Mention picker (allowNew)

Type @al… free text stays valid — pick a teammate or invent a brand-new handle, both land as chips on Enter.

new Autocomplete('#mention', {
  source: q => users(q.replace(/^@/, '')),
  allowNew: true,   // the default — new handles are fine
  onSelect: item => addChip(item.value)
})

Recent searches (openOnFocus)

Focus the empty field: recents appear before you type a single character. Selecting or submitting adds to the list.

new Autocomplete('#recent', {
  source: () => recents,   // empty query → the whole list
  openOnFocus: true
})

Family theming

With the VC core loaded, one call themes every component.

VC.config({ accent: '#b45309' })
// or per page, no core needed:
.vac { --vac-accent: #b45309; }

API reference

Constructor & usage

Bind to any <input> by CSS selector or element. Binding an element that already has an instance destroys the old one first; a target that can't be found throws. Without a DOM (SSR) the constructor returns an inert instance whose methods all no-op.

var ac = new Autocomplete('#q', { source: ['Apple', 'Apricot'] })

Autocomplete.create('#q', {...})   // constructor alias, same signature
Autocomplete.get('#q')             // instance already bound to that element, or null
Autocomplete.autoInit(root?)       // init every [data-vac] under root (default: document)

The source option accepts three shapes — results may be plain strings or item objects (see Options):

// 1. Array — filtered locally (case/diacritic-insensitive substring on label)
source: ['Alpha', 'Beta', { value: 'g', label: 'Gamma' }]

// 2. Callback — call done(results) whenever you're ready
source: function (query, done) {
  fetch('/api?q=' + encodeURIComponent(query))
    .then(r => r.json()).then(done)
}

// 3. Promise — return a thenable that resolves to results
source: query => fetch('/api?q=' + encodeURIComponent(query)).then(r => r.json())

Every lookup carries a sequence token: results arriving for a superseded query are discarded, so a slow response can never overwrite the current list. A rejected promise or thrown source closes the panel silently and calls onError. If a function source both calls done() and returns a promise, whichever settles first wins.

Options

All keys of Autocomplete.defaults, with their exact defaults from the source. Mutate Autocomplete.defaults to change them globally; per-instance options win over defaults.

OptionTypeDefaultDescription
sourcearray | functionnullSuggestion source: array, fn(query, done) callback, or fn(query) returning a promise. See Usage.
minCharsnumber1Fewer typed characters than this keeps the panel closed. Ignored by open() and openOnFocus lookups.
debouncenumber150Milliseconds between the last keystroke and the lookup.
maxResultsnumber10Cap applied after the source answers.
highlightbooleantrueWrap the matched substring of each label in a .vac-match span (built from DOM nodes, never innerHTML). Skipped for items with html: true.
openOnFocusbooleanfalseLook up on focus, ignoring minChars — an empty query matches everything against an array source (recent-searches lists).
allowNewbooleantrueFree text stays valid. false reverts text that matches no suggestion (by value or label, case/diacritic-insensitive) to the last accepted value on blur; clearing the field is always allowed.
emptyTextstring | nullnullText of the "no results" row. null falls back to labels.noResults.
themestring'auto''auto' | 'light' | 'dark'. See Theming.
stylesbooleantruefalse = headless: no CSS injected, style the .vac-* markup yourself.
positionstring'auto''auto' | 'below' | 'above'. Auto flips above when there is no room below and clamps to the viewport.
labelsobjectsee belowUI strings; merged key-by-key with the defaults.
onSelectfunction | nullnullfn(item, autocomplete) — see Events.
onInputfunction | nullnullfn(query, autocomplete) — every keystroke.
onErrorfunction | nullnullfn(error, autocomplete) — source failures.
onOpenfunction | nullnullfn(autocomplete) — panel opened.
onClosefunction | nullnullfn(autocomplete) — panel closed.

labels.*

KeyDefaultUsed for
labels.noResults'No results'Empty-state row (unless emptyText is set).
labels.loading'Loading…'Loading row shown while an async lookup is in flight.
labels.suggestions'Suggestions'aria-label of the listbox.

There are no per-call-only options beyond the defaults keys above.

Item shape

Source results may be plain strings (shorthand for value = label = the string) or objects. A missing value falls back to label and vice versa; both are coerced to strings.

PropertyTypeDefaultDescription
valuestringlabelWhat lands in the input when the item is committed.
labelstringvalueWhat the row shows. Rendered as text via textContent.
groupstring | nullnullItems sharing a group render under an uppercase header.
disabledbooleanfalseRow is skipped by keyboard browsing and can't be chosen.
htmlbooleanfalsePer-item opt-in: render label as trusted markup via innerHTML; skips highlighting.

Instance methods

MethodReturnsDescription
open()thisLook up suggestions for the current text right now (no debounce, ignores minChars) and show them.
close()thisClose the panel; also cancels any in-flight or debounced lookup.
setSource(src)thisSwap the source (array or function); an open panel refreshes in place.
getInput()HTMLInputElement | nullThe bound <input> (null after destroy()).
destroy()thisTear down: remove the panel and all listeners, restore the input's original attributes, orphan any in-flight lookup.

Readable instance properties: input (the bound element), opts (resolved options), isOpen (boolean), and items (the last delivered, normalized results).

Statics & helpers

StaticType / valueDescription
Autocomplete.version'1.0.0'Library version.
Autocomplete.defaultsobjectThe live defaults object documented under Options; mutate to change defaults globally.
Autocomplete.create(target, options)functionConstructor alias; returns the new instance.
Autocomplete.get(target)functionInstance already bound to that selector/element, or null.
Autocomplete.autoInit(root?)functionInitialize every [data-vac] element under root (default document); skips already-bound elements, logs per-element errors without aborting, returns the created instances.
Autocomplete.salt'vc1'CSS isolation token. Set a custom string or false (disable) before the first instance. See Theming.
Autocomplete.cssstring (getter)The full stylesheet, rendered with the current salt — a starting point for headless styling.
Autocomplete.displayName'Autocomplete'Family convergence contract.
Autocomplete.rootClass'vac'Root class of the panel (convergence contract).
Autocomplete.themeVarsobjectMaps family theme keys to CSS custom properties: { accent: '--vac-accent', radius: '--vac-radius', font: '--vac-font' } — used by VC.config().
Autocomplete.varScopesarray['.vac', '.vac[data-theme=dark]'] — the selectors where theme variables are defined (deliberately unsalted).

Events & callbacks

Option callbacks receive the instance as their last argument:

CallbackArgumentsFires when
onSelect(item, autocomplete)A suggestion is committed with Enter or a click. item is the normalized item object (value/label/group/disabled/html).
onInput(query, autocomplete)Every keystroke, with the raw input value, before the debounced lookup.
onError(error, autocomplete)A source throws or its promise rejects; the panel has already closed silently.
onOpen(autocomplete)The panel opens.
onClose(autocomplete)The panel closes.

The component also dispatches bubbling CustomEvents on the bound input, so you can listen without a reference to the instance:

Eventevent.detailFires when
autocomplete:select{ item, autocomplete }A suggestion is committed (after onSelect).
autocomplete:open{ autocomplete }The panel opens (after onOpen).
autocomplete:close{ autocomplete }The panel closes (after onClose).
document.getElementById('q').addEventListener('autocomplete:select', function (e) {
  console.log(e.detail.item.value)
})

All three bubble (bubbles: true). No native input or change events are dispatched programmatically — when a selection fills the input (or allowNew: false reverts it), the value is written directly.

Declarative init

Add data-vac to an input and it is picked up automatically on DOMContentLoaded (or immediately if the script loads later); call Autocomplete.autoInit(root) yourself for content added after that. Already-bound elements are skipped.

<input data-vac data-vac-source='["Apple","Apricot","Banana"]' data-vac-min-chars="2">
AttributeMaps toValue semantics
data-vac—Marker: opts the element in to auto-init.
data-vac-sourcesourceJSON array (strings or item objects); anything that fails to parse as JSON is split on commas as a shorthand list.
data-vac-min-charsminCharsNumber.
data-vac-debouncedebounceNumber (ms).
data-vac-max-resultsmaxResultsNumber.
data-vac-highlighthighlightBoolean: every value except "false" and "0" is true.
data-vac-open-on-focusopenOnFocusBoolean, same parsing.
data-vac-allow-newallowNewBoolean, same parsing.
data-vac-stylesstylesBoolean, same parsing.
data-vac-empty-textemptyTextString.
data-vac-themethemeauto | light | dark.
data-vac-positionpositionauto | below | above.

The component itself sets data-vac-bound on the input while an instance is attached; it is removed on destroy().

Theming

With theme: 'auto' (the default) the panel resolves its theme in the family's order — <html data-theme> / data-bs-theme → .dark / .light class on <html> → prefers-color-scheme — and re-resolves live when any of those change (via the shared VC engine when the core is loaded, otherwise a private watcher). Pin per instance with theme: 'light' or 'dark'. Every color is a CSS custom property on .vac (dark values on .vac[data-theme=dark]):

PropertyLight defaultDark defaultPurpose
--vac-accent#5b5bd6#7b7beaMatch highlight + active tint.
--vac-bg#ffffff#1b1d24Panel background.
--vac-surface#f2f2f5#272a33Active option background.
--vac-text#1c1d21#e9eaf0Option text.
--vac-muted#72747e#989aa6Group headers, status rows.
--vac-faint#e7e7ec#31343fPanel border.
--vac-shadow0 10px 28px rgba(24,25,32,.14), 0 2px 8px rgba(24,25,32,.08)0 10px 28px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.35)Panel shadow.
--vac-radius12px(same)Panel corner radius.
--vac-fontsystem-ui stack(same)Panel font family.
.vac { --vac-accent: #b45309; }   /* per page — no core needed */
VC.config({ accent: '#b45309' })  /* with the VC core: themes the whole family */

CSS isolation: the panel renders as class="vac vac-panel vc1" and every structural rule ships salted (.vac.vc1 .vac-option { … }) so host-page design systems can't override it, while the --vac-* variable definitions stay deliberately unsalted so page overrides keep working. Set Autocomplete.salt = 'acme' for a custom token or Autocomplete.salt = false to disable — before the first instance. The host <input> itself is never styled.

Headless: pass styles: false (or set Autocomplete.defaults.styles = false) and no CSS is ever injected; you keep the full behavior — async guards, keyboard, ARIA — and style the markup contract below yourself. The stock stylesheet is available as the Autocomplete.css string as a starting point.

input[data-vac-bound]                 ← your input, untouched
.vac.vac-panel[data-theme="dark"]     ← .vac-open while visible;
  .vac-list[role="listbox"]             is-loading / is-empty state classes
    [role="group"] > .vac-group-label ← only when items carry `group`
    .vac-option.is-active             ← .vac-match wraps the matched substring
  .vac-loading                        ← spinner row while a lookup is in flight
  .vac-empty                          ← shown when query ≥ minChars, no results

Accessibility

The bound input becomes the combobox: it gets role="combobox", aria-autocomplete="list", aria-expanded, aria-controls (pointing at the list) and autocomplete="off"; original attribute values are restored on destroy(). The panel's list is role="listbox" (labelled by labels.suggestions) with role="option" rows carrying aria-selected and, when disabled, aria-disabled="true"; grouped items sit inside role="group" containers labelled with the group name. Browsing moves aria-activedescendant on the input only — the typed text is never changed until a commit. Reduced motion is respected (prefers-reduced-motion disables all transitions).

KeyAction
↓ / ↑Move the active suggestion; wraps around and skips disabled items. Either arrow on a closed panel reopens it with suggestions for the current text.
EnterWith an active suggestion: commit it (fills the input with item.value, fires onSelect, closes; default prevented). With nothing active: the free text stands, the panel closes, and the Enter reaches your form.
EscClose the panel; the typed text is kept. (Default prevented and propagation stopped, so a surrounding dialog stays open.)
TabClose and let focus move on naturally.
typingReopens with fresh suggestions, debounced by debounce ms.

Blur closes the panel; with allowNew: false it also reverts unmatched text first. Home and End are not intercepted — they keep their native caret behavior in the input.

Headless mode

styles: false injects nothing — you keep the full behavior (async guards, keyboard navigation), the ARIA combobox wiring and the stable .vac-* class hooks; you bring your own CSS. Each demo runs in its own <iframe>: the styled examples above already injected the autocomplete stylesheet into this page, so only a clean document can show truly unstyled output.

Unstyled (styles: false)

The raw markup contract — your input untouched, a plain listbox panel with class hooks, zero CSS injected. Type a.

new Autocomplete('#q', {
  styles: false,        // zero CSS injected
  source: ['Amsterdam', 'Auckland',
    'Austin', 'Barcelona', 'Berlin'],
  minChars: 1,
  openOnFocus: true
})

Bring your own design (Tailwind)

The same headless instance mapped to a different design — groups, active option and match highlight — loaded on demand from cdn.tailwindcss.com.

<style type="text/tailwindcss">
.vac-panel { @apply z-10 overflow-hidden rounded-2xl
  border border-slate-200 bg-white shadow-2xl; }
.vac-list { @apply max-h-60 overflow-y-auto p-1.5; }
.vac-group-label { @apply px-3 pb-1 pt-2 text-[11px]
  font-bold uppercase tracking-widest text-indigo-400; }
.vac-option { @apply cursor-pointer rounded-xl px-3 py-2
  text-sm text-slate-700; }
.vac-option.is-active { @apply bg-indigo-600 text-white; }
.vac-match { @apply font-extrabold; }
.vac-loading, .vac-empty { @apply px-3 py-6 text-center
  text-sm text-slate-400; }
</style>