@magicx-eng/ai-autocomplete-vanilla
v0.1.19
Published
AI Autocomplete — framework-agnostic vanilla JS library
Maintainers
Readme
@magicx-eng/ai-autocomplete-vanilla
A framework-agnostic vanilla JS/TypeScript library that provides a guided AI-powered autocomplete experience with pill-based input and dropdown suggestions. Works with any tech stack — React, Vue, Svelte, Angular, or plain HTML.
Features
- Three tiers of integration — full drop-in component, dropdown-only, or fully headless
- Zero dependencies — no React, no framework, no virtual DOM
- Rich inline input — a single
contentEditablesurface: typed text, bold completed params, and inline pills all share one editing context - Pill-based input — non-editable inline pills for unfilled parameters, bold inline text for completed ones
- Instant exact-match bolding — typing the full text of an option immediately promotes it to a completed param (no debounced fetch wait). Works in the normal typing flow and while re-editing an existing completed param.
- Re-edit completed params — tap a bold completed param to replace it; the dropdown re-opens with the cached options the server originally returned for that param
- Light/dark mode — built-in themes with
prefers-color-schemesupport, fully overridable via CSS variables - Access token auth — short-lived tokens with automatic refresh, single-flight deduplication, and 401 retry
- Keyboard navigation — arrow keys, enter to submit, tab to autocomplete, backspace to un-bold the last completed param
- Client-side filtering — instant substring filtering on every keystroke
- Option overrides — inject or dynamically generate client-side options per suggestion type
- Controlled & uncontrolled — works out of the box or integrates with external state
- Accessible — ARIA combobox 1.2 pattern with
role="listbox",aria-activedescendant - IME-safe — composition events are buffered so input text is committed once, after composition ends
- Animations — option selection streak animation, text shimmer on newly added params
- Loading skeleton — while a fetch is in flight, the dropdown and inline pills keep the previous layout (same count and widths) with their text masked and a shimmer pulse. The skeleton is held back until the selection streak animation finishes, so taps don't visually "stutter" into loading.
- Lightweight — ~10 KB gzipped, styles auto-injected at runtime
- TypeScript first — full type definitions shipped with the package
- SSR-safe — no top-level
document/windowaccess
Installation
pnpm add @magicx-eng/ai-autocomplete-vanillaThree Tiers
| Tier | What you get | What you own | Use when | |---|---|---|---| | Tier 1: Full | Input + dropdown + pills + keyboard + state | Nothing — drop in and go | You want a complete widget with zero setup | | Tier 2: Dropdown | Dropdown + pills + keyboard + state | The input element | You need a custom input (e.g. rich text, search bar) | | Tier 3: Headless | State + controllers + derived data | All DOM | You're building a framework wrapper (React, Vue, Svelte) or need full rendering control |
Tier 1: Full Component
Drop-in. The library creates and owns the input, dropdown, pills, and all DOM.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const ac = new AIAutocomplete(document.getElementById("root"), {
apiConfig: {
endpoint: "https://api.example.com/ac/suggest",
apiKey: "your_api_key",
},
onSubmit: (result) => {
console.log(result.query); // "Create a email"
console.log(result.raw_query); // "Create a {{TASK_1}}"
console.log(result.completed_params); // [{ placeholder: "{{TASK_1}}", type: "task", ... }]
},
});No CSS import needed — styles are auto-injected on first instantiation.
Options
const ac = new AIAutocomplete(container, {
// Tier: "full" (default) | "dropdown" | "headless"
renderMode: "full",
// API
apiConfig: { apiKey: "...", authScheme: "Bearer", endpoint: "https://api.ai-autocomplete.com/api/suggest" },
optionOverrides: { account: (query) => [...] },
columns: 2,
maskCompletedText: false, // when true, omits completed params' text from API requests (PII masking)
// Appearance
mode: "auto", // "light" | "dark" | "auto"
optionsPosition: "below", // "above" | "below"
animations: true, // enable streak + shimmer
pillPlacement: "inline", // "inline" | "dropdown" | "hidden"
dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
showNonTappableOptions: true, // false = hide non-tappable options from the dropdown
// Focus
autoFocus: true, // focus the input on mount (Tier 1 only)
// Custom submit button (Tier 1 only)
// - undefined (default): renders the built-in arrow button
// - null: no submit button at all
// - HTMLElement: replaces the default button. Click events bubble up and trigger submit.
submitButton: undefined,
// Events
onSubmit: (result) => { ... },
onError: (error) => { ... },
onChange: (text) => { ... },
onParamsChange: (params) => { ... },
onFocus: () => { ... },
onBlur: () => { ... },
// Controlled mode
value: "initial text",
completedParams: [],
});Methods
ac.focus(); // Focus the input
ac.blur(); // Blur the input
ac.reset(); // Clear everything, re-fetch, and start a new session
ac.destroy(); // Remove DOM, listeners, timers
ac.setMode("dark"); // Switch color mode
ac.update({ animations: false, optionsPosition: "above" });Tier 1 auto-resets after both Enter key and built-in submit-button clicks — your
onSubmitruns, then the SDK clears the input and starts a new session. You don't need to callac.reset()yourself in Tier 1.
autoFocus,focus(), andblur()only operate on the contentEditable editor the library owns (Tier 1 /renderMode: "full"). In Tier 2/3, the consumer owns the input and calls.focus()/.blur()on their own element; theonFocus/onBlurcallbacks still fire wheneversetFocused()is called.
Backspace into a completed param — pressing Backspace while the caret is inside (or immediately after) a bold completed param drops the param's "completed" status and removes one grapheme before the caret. The remaining text stays in the editor as plain (un-bold) text so the user can keep editing instead of losing the whole phrase.
Re-edit a completed param — tapping a bold completed param enters re-edit mode. The dropdown re-opens with the cached options the server originally returned for that param. Typing atomically replaces the bold (and re-promotes it to bold if what you type exactly matches one of the cached options); clicking an option replaces it with the new selection; pressing Escape or moving the caret out exits without changes.
Caret placement after a completion — whenever a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret lands right after the trailing space following the bold so typing can continue immediately. A space is inserted if one wasn't already there.
Custom submit button
const myButton = document.createElement("button");
myButton.className = "my-button";
myButton.textContent = "Go";
new AIAutocomplete(container, {
apiConfig: { apiKey: "..." },
onSubmit: (result) => { ... },
submitButton: myButton, // replaces the default; clicks trigger submit
});
// Or hide the button entirely:
new AIAutocomplete(container, { ..., submitButton: null });Tier 2: Dropdown Only
You own the input. The library renders the dropdown with pills inside a container you provide.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const input = document.getElementById("my-input");
const dropdownContainer = document.getElementById("my-dropdown");
const ac = new AIAutocomplete(dropdownContainer, {
renderMode: "dropdown",
apiConfig: { apiKey: "your_api_key" },
onChange: (text) => {
// Keep your input in sync when the library updates text (e.g. option selected)
if (input.value !== text) input.value = text;
},
onSubmit: (result) => console.log(result),
});
// Wire your input → library
input.addEventListener("input", () => ac.handleTextChange(input.value));
input.addEventListener("keydown", (e) => ac.handleKeyDown(e));
input.addEventListener("focus", () => ac.setFocused(true));
input.addEventListener("blur", () => ac.setFocused(false));Pills always render inside the dropdown in this mode. The library creates the dropdown DOM inside your container — you just provide the element and wire up your input's events.
Focus/blur wiring is required when
dropdownTriggeris"auto"(the default) — the dropdown only opens while the input is focused. WithoutsetFocused(), the dropdown will never open.
Tier 3: Headless
No DOM at all. You get state, controllers, and derived data. You render everything yourself.
This is what framework wrappers (React, Vue, Svelte) use under the hood.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const ac = new AIAutocomplete(document.createElement("div"), {
renderMode: "headless",
apiConfig: { apiKey: "your_api_key" },
onSubmit: (result) => console.log(result),
});
// Read state
const state = ac.getState();
// state.text, state.completedParams, state.suggestions,
// state.segments, state.filteredOptions, state.actionableSuggestions,
// state.placeholderText, state.isDropdownOpen, state.activeDropdownIndex,
// state.newParamId, state.isLoading, state.isReady, state.error
// Subscribe to state changes. The listener receives the full state shape
// (raw inputs + derived fields like `segments`, `actionableSuggestions`,
// `filteredOptions`, `placeholderText`, `isDropdownOpen`).
const unsub = ac.subscribe((state) => {
renderMyInput(state.text, state.placeholderText);
renderMyPills(state.actionableSuggestions);
renderMyDropdown(state.filteredOptions, state.activeDropdownIndex, state.isDropdownOpen);
renderMySegments(state.segments, state.newParamId);
});
// Forward user actions → library
myInput.addEventListener("input", () => ac.handleTextChange(myInput.value));
myInput.addEventListener("keydown", (e) => ac.handleKeyDown(e));
myInput.addEventListener("focus", () => ac.setFocused(true));
myInput.addEventListener("blur", () => ac.setFocused(false));
myOptionEl.addEventListener("click", () => ac.selectOption(option));
myPillEl.addEventListener("click", () => ac.setActivePill(index));
// Cleanup
ac.destroy();
unsub();State shape (CoreState)
| Field | Type | Description |
|---|---|---|
| text | string | Current input text |
| completedParams | CompletedParamState[] | Filled parameters |
| suggestions | Suggestion[] | All suggestions from server (including placeholder type) |
| actionableSuggestions | Suggestion[] | Non-placeholder suggestions (the pills) |
| filteredOptions | SuggestionOption[] | Options for the active suggestion, filtered by current query |
| segments | Segment[] | Input text split into typed text vs completed params — completed segments render as bold <strong> runs inside the editor |
| placeholderText | string | Placeholder text from server suggestions (joined placeholder-type suggestion texts) |
| activeDropdownIndex | number | Highlighted option index (-1 = none) |
| isDropdownOpen | boolean | Whether the dropdown should be visible |
| newParamId | string \| null | ID of the most recently added param (for shimmer animation) |
| isLoading | boolean | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on !inSelectionAnimation and !editingParam. |
| inSelectionAnimation | boolean | True for the 500 ms after a user-initiated option tap so the streak animation can finish before the dropdown switches to the loading skeleton. |
| editingParam | CompletedParamState \| null | When non-null, the user is re-editing a bold completed param; cached options remain visible and the loading skeleton is suppressed. |
| isReady | boolean | Server indicates query is complete |
| error | Error \| null | Last fetch error |
Actions
| Method | Description |
|---|---|
| handleTextChange(value) | Call when user types. Handles capitalization, param reconciliation, filter updates. |
| handleKeyDown(event) | Forward keyboard events. Handles arrow nav, Enter, Tab, Escape. |
| setFocused(focused) | Notify the library that the input has focus. Required for dropdownTrigger: "auto" in Tier 2/3 — the dropdown only opens while focused. |
| selectOption(option) | Select a dropdown option. Updates text, creates completed param, triggers shimmer. |
| setActiveDropdownIndex(index) | Set the highlighted option (for mouse hover). |
| setActivePill(index) | Reorder pills — moves pill at index to front (active). |
| removeLastParam() | Remove the last completed param from state. The text stays in the input as plain text. |
| clearNewParamId() | Clear shimmer animation state. |
| reset() | Clear all state, re-fetch initial suggestions, and start a new session (rotates session_id). Call this after handling submit. |
| setValue(text) | Set text (controlled mode). |
| setCompletedParams(params) | Set completed params (controlled mode). |
| setMode(mode) | Switch color mode at runtime. |
| update(opts) | Update multiple options at once (e.g. animations, pillPlacement, dropdownTrigger, optionsPosition). |
| getState() | Get a snapshot of the current state. Useful for initial read before subscribing. |
| subscribe(listener) | Subscribe to state changes. The listener receives the full state (raw inputs + derived fields). Returns unsubscribe function. |
| destroy() | Cleanup — abort fetches, clear timers, remove listeners. |
Building a framework wrapper
The pattern for any framework:
- Create a headless instance on mount
- Subscribe to state → map to framework reactivity (
useState,ref(),writable()) - Render your own components using the state
- Forward user events (input, keydown, click) → core actions
- Destroy on unmount
API Reference
APIConfig
A discriminated union: APIKeyConfig | AccessTokenConfig.
API Key Mode (default)
{
apiKey: "your_api_key",
authScheme: "Bearer", // or "Basic"
endpoint: "https://api.ai-autocomplete.com/api/suggest", // optional, this is the default
appIdentifier: "my-app", // optional
headers: { "X-Custom": "v" }, // optional
}Access Token Mode
{
type: "accessToken",
getAccessToken: async () => {
const res = await fetch("/api/ac-token");
const { access_token, expires_at } = await res.json();
return { accessToken: access_token, expiresAt: expires_at };
},
endpoint: "https://api.ai-autocomplete.com/api/suggest", // optional, this is the default
}The SDK handles token refresh transparently: if a request returns 401, it calls getAccessToken once, retries, and only surfaces an error if the retry also fails. Concurrent 401s share a single refresh call. Tokens are refreshed proactively 30 seconds before expiresAt.
AutocompleteResult
The object passed to onSubmit:
| Field | Type | Description |
|---|---|---|
| query | string | Plain text as the user sees it. |
| raw_query | string | Text with placeholder tokens (e.g. "Create a {{TASK_1}}"). |
| completed_params | CompletedParam[] | Array of filled parameter values. |
Event Subscription
const off = ac.on("submit", (result) => { ... });
off(); // unsubscribe
// Multiple listeners on the same event are supported — each `on()` call adds
// a listener; the returned function removes only the listener it registered.
const offA = ac.on("change", logToAnalytics);
const offB = ac.on("change", syncToStore);Events: submit, error, change, paramsChange, stateChange, focus, blur.
Constructor callbacks (onSubmit, onChange, etc.) are registered once at construction as the initial listener for that event. Use on() for any additional or replacement listeners. update() does not swap event listeners — use on() for dynamic listener management.
subscribe() vs on(): Use on() for specific events (submit, error, text change). Use subscribe() (Tier 3) for full state updates — every subscribe callback receives the merged input + derived state, ready to read.
CSS Customization
Styles are auto-injected at runtime (Tier 1 and Tier 2). No CSS import needed. The component ships built-in light and dark defaults.
CSS Variables
Override these on the container element. All built-in defaults use :where() (zero specificity) — your overrides always win without !important.
| Variable | Default (light) | Default (dark) | Description |
|---|---|---|---|
| --aia-font-family | inherit | inherit | Font used by the library. Defaults to inherit so the library picks up your page's font automatically. Set this to pin a specific font on the library without changing the surrounding page. |
| --aia-pill-bg | #bdbdbd | #bdbdbd | Pill background |
| --aia-pill-color | #000000 | #ffffff | Pill text |
| --aia-pill-font-size | 19px | 19px | Pill font size |
| --aia-option-bg | transparent | transparent | Option background (highlighted) |
| --aia-option-color | #000000 | #ffffff | Option text |
| --aia-option-color-selected | #000000 | #ffffff | Highlighted option text |
| --aia-option-font-size | 19px | 19px | Option font size |
| --aia-written-text-color | #000000 | #ffffff | Input text |
| --aia-written-text-font-size | 19px | 19px | Input text font size |
| --aia-caret-color | --aia-written-text-color | --aia-written-text-color | Editor caret color. Override independently of input text color. |
| --aia-submit-bg | #000000 | #ffffff | Submit button background |
| --aia-submit-color | #ffffff | #000000 | Submit button icon color |
| --aia-dropdown-bg | — | — | Optional bg color the dropdown's "glass" rim shadow tints toward. Set this to the page background behind the dropdown so the bottom-corner glow blends seamlessly. |
| --aia-scrollbar-thumb | rgba(0, 0, 0, 0.3) | rgba(0, 0, 0, 0.3) | Color of the option list's scrollbar thumb (Firefox + WebKit). |
| --aia-streak-rgb | 99, 102, 241 | 255, 255, 255 | Comma-separated RGB triplet used to tint the option-selection streak animation (consumed via rgba(var(--aia-streak-rgb), …)). |
| --aia-streak-glass-bg | rgba(99, 102, 241, 0.1) | rgba(255, 255, 255, 0.1) | Background fill for the streak's glass-pill effect. |
| --aia-skeleton-bg | rgba(189, 189, 189, 0.51) | #333539 | Fill color for the loading skeleton bars and the masked text in cached pills/options. |
Per-mode Overrides
#my-autocomplete[data-mode="light"] {
--aia-pill-bg: #e2e8f0;
}
#my-autocomplete[data-mode="dark"] {
--aia-pill-bg: #334155;
}Live Preview
document.getElementById("my-autocomplete")
.style.setProperty("--aia-pill-bg", newColor);Selector Hooks
For styling beyond the CSS variables, target these stable data-aia-* attributes:
| Attribute | Element |
|---|---|
| [data-aia-editor] | Editor area wrapping the contentEditable + inline pill list |
| [data-aia-input] | The contentEditable <div> that owns typed text and bold completed params. Replaces the previous [data-aia-textarea] selector. |
| [data-aia-pill-list-container] | Inline sibling of the editor that holds unfilled-suggestion pills |
| [data-aia-submit] | Submit button |
| [data-aia-pill] | Each unfilled-suggestion pill |
| [data-aia-pillbar] | Pill bar container inside the dropdown |
| [data-aia-option] | Each suggestion option |
| [data-aia-dropdown] | The dropdown root (listbox) |
Completed params render as inline <strong> elements inside the editor. Override their weight with [data-aia-input] strong { font-weight: 700; } (the built-in style uses :where() so any consumer selector wins without !important).
The vanilla core also exposes stable BEM class names (magicx-aia-*); both can be used.
/* Solid (non-glass) dropdown */
#my-autocomplete [data-aia-dropdown] {
background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: none;
}Sessions
Every /api/suggest request carries a meta.session_id UUID. A session runs from instance construction (or the last reset()) until the next reset(). All requests in one session share the same session_id; calling reset() starts a new one.
- Tier 1 (
renderMode: "full") — the SDK auto-resets after both Enter key and built-in submit-button clicks. YouronSubmitruns first, then the SDK clears the input and starts a new session. - Tier 2/3 (
renderMode: "dropdown" | "headless") — you own the input and submit flow, so you must callac.reset()yourself from youronSubmithandler (or from your custom submit button after firingonSubmit).
Why it matters: the server uses
session_idto track each session's history — what the user has been typing sequentially, which options they've selected, and how the query evolved. That context lets the model produce better, more relevant suggestions on subsequent requests within the same session. Callingreset()at the right moment (when the user actually submits) keeps that history accurate, so your users get higher-quality results.
// Tier 2/3 — call reset() yourself
const ac = new AIAutocomplete(container, {
renderMode: "dropdown",
apiConfig: { apiKey: "..." },
onSubmit: (result) => {
sendToBackend(result);
ac.reset(); // start the next session
},
});Option Overrides
new AIAutocomplete(el, {
optionOverrides: {
account: () => [
{ text: "Savings", is_tappable: true, kind: null },
{ text: "Checking", is_tappable: true, kind: null },
],
value: (query) => {
const digits = query.replace(/\D/g, "");
if (!digits) return [{ text: "$100", is_tappable: true, kind: null }];
return [{ text: `$${digits}`, is_tappable: true, kind: null }];
},
},
onSubmit: handleSubmit,
});License
Private package. All rights reserved.
