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.
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)
})
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)
})
Items with a group render under uppercase headers,
exactly like Select.
source: [
{ value: 'js', label: 'JavaScript', group: 'Dynamic' },
{ value: 'rs', label: 'Rust', group: 'Compiled' },
…
]
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)
})
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
})
With the VC core loaded, one call themes every component.
VC.config({ accent: '#b45309' })
// or per page, no core needed:
.vac { --vac-accent: #b45309; }
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.
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.
| Option | Type | Default | Description |
|---|---|---|---|
source | array | function | null | Suggestion source: array, fn(query, done) callback, or fn(query) returning a promise. See Usage. |
minChars | number | 1 | Fewer typed characters than this keeps the panel closed. Ignored by open() and openOnFocus lookups. |
debounce | number | 150 | Milliseconds between the last keystroke and the lookup. |
maxResults | number | 10 | Cap applied after the source answers. |
highlight | boolean | true | Wrap the matched substring of each label in a .vac-match span (built from DOM nodes, never innerHTML). Skipped for items with html: true. |
openOnFocus | boolean | false | Look up on focus, ignoring minChars — an empty query matches everything against an array source (recent-searches lists). |
allowNew | boolean | true | Free 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. |
emptyText | string | null | null | Text of the "no results" row. null falls back to labels.noResults. |
theme | string | 'auto' | 'auto' | 'light' | 'dark'. See Theming. |
styles | boolean | true | false = headless: no CSS injected, style the .vac-* markup yourself. |
position | string | 'auto' | 'auto' | 'below' | 'above'. Auto flips above when there is no room below and clamps to the viewport. |
labels | object | see below | UI strings; merged key-by-key with the defaults. |
onSelect | function | null | null | fn(item, autocomplete) — see Events. |
onInput | function | null | null | fn(query, autocomplete) — every keystroke. |
onError | function | null | null | fn(error, autocomplete) — source failures. |
onOpen | function | null | null | fn(autocomplete) — panel opened. |
onClose | function | null | null | fn(autocomplete) — panel closed. |
| Key | Default | Used 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.
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.
| Property | Type | Default | Description |
|---|---|---|---|
value | string | label | What lands in the input when the item is committed. |
label | string | value | What the row shows. Rendered as text via textContent. |
group | string | null | null | Items sharing a group render under an uppercase header. |
disabled | boolean | false | Row is skipped by keyboard browsing and can't be chosen. |
html | boolean | false | Per-item opt-in: render label as trusted markup via innerHTML; skips highlighting. |
| Method | Returns | Description |
|---|---|---|
open() | this | Look up suggestions for the current text right now (no debounce, ignores minChars) and show them. |
close() | this | Close the panel; also cancels any in-flight or debounced lookup. |
setSource(src) | this | Swap the source (array or function); an open panel refreshes in place. |
getInput() | HTMLInputElement | null | The bound <input> (null after destroy()). |
destroy() | this | Tear 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).
| Static | Type / value | Description |
|---|---|---|
Autocomplete.version | '1.0.0' | Library version. |
Autocomplete.defaults | object | The live defaults object documented under Options; mutate to change defaults globally. |
Autocomplete.create(target, options) | function | Constructor alias; returns the new instance. |
Autocomplete.get(target) | function | Instance already bound to that selector/element, or null. |
Autocomplete.autoInit(root?) | function | Initialize 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.css | string (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.themeVars | object | Maps family theme keys to CSS custom properties: { accent: '--vac-accent', radius: '--vac-radius', font: '--vac-font' } — used by VC.config(). |
Autocomplete.varScopes | array | ['.vac', '.vac[data-theme=dark]'] — the selectors where theme variables are defined (deliberately unsalted). |
Option callbacks receive the instance as their last argument:
| Callback | Arguments | Fires 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:
| Event | event.detail | Fires 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.
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">
| Attribute | Maps to | Value semantics |
|---|---|---|
data-vac | — | Marker: opts the element in to auto-init. |
data-vac-source | source | JSON array (strings or item objects); anything that fails to parse as JSON is split on commas as a shorthand list. |
data-vac-min-chars | minChars | Number. |
data-vac-debounce | debounce | Number (ms). |
data-vac-max-results | maxResults | Number. |
data-vac-highlight | highlight | Boolean: every value except "false" and "0" is true. |
data-vac-open-on-focus | openOnFocus | Boolean, same parsing. |
data-vac-allow-new | allowNew | Boolean, same parsing. |
data-vac-styles | styles | Boolean, same parsing. |
data-vac-empty-text | emptyText | String. |
data-vac-theme | theme | auto | light | dark. |
data-vac-position | position | auto | below | above. |
The component itself sets data-vac-bound on the input while an
instance is attached; it is removed on destroy().
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]):
| Property | Light default | Dark default | Purpose |
|---|---|---|---|
--vac-accent | #5b5bd6 | #7b7bea | Match highlight + active tint. |
--vac-bg | #ffffff | #1b1d24 | Panel background. |
--vac-surface | #f2f2f5 | #272a33 | Active option background. |
--vac-text | #1c1d21 | #e9eaf0 | Option text. |
--vac-muted | #72747e | #989aa6 | Group headers, status rows. |
--vac-faint | #e7e7ec | #31343f | Panel border. |
--vac-shadow | 0 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-radius | 12px | (same) | Panel corner radius. |
--vac-font | system-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
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).
| Key | Action |
|---|---|
↓ / ↑ | Move the active suggestion; wraps around and skips disabled items. Either arrow on a closed panel reopens it with suggestions for the current text. |
Enter | With 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. |
Esc | Close the panel; the typed text is kept. (Default prevented and propagation stopped, so a surrounding dialog stays open.) |
Tab | Close and let focus move on naturally. |
| typing | Reopens 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.
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.
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
})
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>