@magicx-eng/ai-autocomplete-react
v0.32.0
Published
AI Autocomplete React SDK — guided autocomplete with pill-based input and dropdown suggestions
Maintainers
Readme
@magicx-eng/ai-autocomplete-react
A React/TypeScript SDK that provides a guided AI-powered autocomplete experience with pill-based input and dropdown suggestions. Powered by @magicx-eng/ai-autocomplete-vanilla under the hood.
Features
- Three tiers of integration — the full
<AIAutocomplete />component,useAIAutocomplete()+<AIAutocompleteDropdown />(our dropdown, your input), oruseAIAutocomplete()alone (render the dropdown yourself) - Rich inline input (Tier 1) — a single
contentEditablesurface: typed text, bold completed params, and inline pills 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
- Pill placement — render pills inline in the input or inside the dropdown
- Light/dark mode — built-in themes with
prefers-color-schemesupport, fully overridable via CSS variables - Inherits your font — defaults to the host page's font, with
--aia-font-familyto pin a specific font on the library - 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
- IME-safe — composition events are buffered so input text is committed once, after composition ends
- Client-side filtering — instant substring filtering on every keystroke
- Datepicker — date parameters are answered with a calendar instead of an option list. Click a day or navigate with the arrow keys; the date is committed the way it would be written —
Tuesdayfor a date inside the next week,March 23for one later this year,March 23 2027for another year. Tapping a committed date re-opens the calendar on the month that text names now — for a weekday name, that is the next such day, not the one originally picked. A parameter whose options are written as spans (September 11 - October 13) gets the same calendar answered in two clicks, committed as one range. - Option overrides — supply the options for a parameter yourself: a fixed list, a computed one, or one fetched from your own search endpoint as the user types
- Product strip — the dropdown renders a horizontal row of product cards below the options, filled by default with the items the
/suggestresponse reports as matching the query; plug in your own product search instead withproducts, or turn the strip off withshowProducts={false} - Catalog report — every response says which of the query's constraints the catalog applied, which it had to drop, and how many items are left, on
customFields/custom_fields - Controlled & uncontrolled — works out of the box or integrates with external state
- Ref forwarding — imperative
focus(),blur(),reset(), andsetMode()via ref - Accessible — ARIA combobox 1.2 pattern with
role="listbox",aria-activedescendant - Animations — option press (the picked option compresses while the rest step back), 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 option-press animation finishes, so taps don't visually "stutter" into loading.
- Lightweight — styles auto-injected at runtime
- TypeScript first — full type definitions shipped with the package
Installation
pnpm add @magicx-eng/ai-autocomplete-reactPeer Dependencies
React 17 or later:
pnpm add react react-domThree Tiers
| Tier | What you get | What you own | Use when |
|---|---|---|---|
| Tier 1: Full | <AIAutocomplete /> — input, dropdown, pills, state | Nothing — drop in and go | You want a complete widget with zero setup |
| Tier 2: Hook + dropdown | useAIAutocomplete() + <AIAutocompleteDropdown /> | The input element and layout | You need a custom input but want our dropdown UI |
| Tier 3: Headless | useAIAutocomplete() alone — state, actions, dropdownProps data | Everything, including the dropdown | You need full control over every piece of the UI |
Tier 1: Full Component
Drop-in component that owns the input, pills, dropdown, and all state:
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-react";
function App() {
return (
<AIAutocomplete
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}}", parameter: "task", ... }]
}}
className="my-autocomplete"
/>
);
}Controlled Mode
const [text, setText] = useState("");
const [params, setParams] = useState([]);
<AIAutocomplete
value={text}
onChange={setText}
completedParams={params}
onParamsChange={setParams}
onSubmit={(result) => console.log(result)}
/>Imperative Handle
import { useRef } from "react";
import { AIAutocomplete, type AIAutocompleteHandle } from "@magicx-eng/ai-autocomplete-react";
const ref = useRef<AIAutocompleteHandle>(null);
// ref.current?.focus()
// ref.current?.blur()
// ref.current?.reset()
// ref.current?.setMode("dark")
<AIAutocomplete ref={ref} onSubmit={handleSubmit} />Focus Control
The component auto-focuses the contentEditable editor on mount. To opt out, pass autoFocus={false}. Listen to focus changes with onFocus / onBlur:
<AIAutocomplete
autoFocus={false}
onFocus={() => setIsActive(true)}
onBlur={() => setIsActive(false)}
onSubmit={handleSubmit}
/>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. From there:
- Typing atomically replaces the bold with what you type. If what you type exactly matches one of the cached options, it's re-promoted to a bold completed param immediately.
- Clicking an option replaces the bold with the new selection.
- Arrow keys, Escape, or clicking outside the param exit re-edit mode without changing anything.
After a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret always lands right after the trailing space following the bold so typing can continue immediately. A space is inserted if one wasn't already there.
Datepicker
When the next parameter is a date, the dropdown shows a calendar instead of a
list of options. A parameter counts as a date when its name says so, or when at
least three of the options it offers are themselves written as dates — so a
parameter called when or arrival still gets a calendar. Options written as
relative phrases (today, next week) are not read as dates; a parameter
offering those needs a name that says date.
Nothing is required to switch it on — a date parameter renders this way
automatically, and every other parameter is unaffected. Tier 1 and Tier 2
render the calendar for you; in Tier 3 the same data reaches dropdownProps so
you can render your own (see Tier 3).
What the user can do
| Input | Result | |---|---| | Click a day | Commits that date | | ↓ | Moves into the calendar, starting on today | | ← / → | Previous / next day, crossing week boundaries | | ↑ / ↓ | Previous / next week. Past either end of the month, focus returns to the input | | Enter | Commits the highlighted day | | Month arrows | Page the calendar. Paging never commits anything | | → at the end of the input | Skips the parameter, same as any other pill |
The committed value is written the way a person would write it, always in English regardless of the visitor's locale, so the value you receive has one vocabulary everywhere:
- a date inside the next week (today included) commits as its weekday name —
Tuesday. Like the sentence it sits in, that name is relative: read later, it means the next such day; - one further out in the current year as
MONTH DAY—March 23; - one in another year as
MONTH DAY YEAR—March 23 2027.
It arrives as an ordinary completed parameter: bold in the input,
and present in completed_params on submit like any other answer.
Re-editing a committed date re-opens the calendar on the month the committed text names, with that day marked, so changing an answer takes one click. For a weekday name that reading is relative, so once the day it named has gone by, re-editing marks the next such day rather than the one originally picked — the calendar shows what the sentence says today, which is also what gets submitted.
Reading the value back. The date arrives as text, so if you need a Date
object, parseDate is exported for it:
import { parseDate } from "@magicx-eng/ai-autocomplete-react";
// Reads all three committed shapes: a weekday name is the one date it can
// mean in the next seven days, and a yearless month-day is the current year.
const due = parseDate("March 23"); // Date, or null if the text isn't one of oursparseLooseDate is exported too, for the looser shapes a person types into a
query — it reads september 5th, 5th September, Sept 5 and 2026-09-05,
and answers null for anything ambiguous rather than guessing.
Dates the user writes themselves. When someone types a date into their query — "fly to vegas on september 5th" — the parameter it answers is recognised in place and the date becomes tappable. Tapping it opens the calendar on that month with the day already marked, so confirming or changing it takes one tap, and the picked date replaces their words with the canonical form.
The written date is read where it can be: september 5th, 5th September,
Sept 5, September 5, 2026 and 2026-09-05 all resolve, in any of those
orders and with or without the year (a date with no year means its next
occurrence). Two cases deliberately don't pre-select — a numeric date like
09/05, which is September 5th in the US and May 9th elsewhere with nothing to
say which, and phrases like next friday. Those still open the calendar, just
on the current month with nothing marked, so the user picks rather than being
shown a guess that might be wrong.
Date ranges. A parameter whose options are written as spans — `September 11
- October 13
,September 11 to November 16` — is answered by the same calendar in two clicks: the first sets the start, the second the end, and the pair commits as one answer. The days between them band as the user moves across the calendar, so the span being chosen is visible before it is committed. Paging the month between the two clicks is expected — that is how a span crosses months — and Esc drops the start and lets them begin again. Clicking the end before the start is fine; the two are ordered on the way in.
The span is read off the options the same way a single date is read off its own:
either separator (-, –, —, to, through, until), either half in any
date format the SDK reads, and a name that says so (date_range, dateRange)
counts too. It commits as one parameter, written the way it was offered —
September 11 - October 13, with the year on whichever end needs it — and both
ends are also recorded on the completed parameter's metadata as YYYY-MM-DD,
under the DATE_RANGE_META_START / DATE_RANGE_META_END keys this package
exports (aiaDateRangeStart / aiaDateRangeEnd), so you never have to parse
the prose. Re-editing one re-opens the calendar with both ends marked.
Typing while the calendar is open does not filter it. The text is treated as a new query, so suggestions refresh as the user types — useful when someone would rather describe what they want than pick a day.
Tier 2: Hook + Dropdown
Use the hook to drive state and render our dropdown; you own the input element and layout:
import { useAIAutocomplete, AIAutocompleteDropdown } from "@magicx-eng/ai-autocomplete-react";
function App() {
const {
completedParams,
suggestionPills,
segments,
inputProps,
dropdownProps,
isLoading,
error,
reset,
} = useAIAutocomplete({
onSubmit: (result) => {
handleMySubmit(result);
reset(); // start a new session
},
apiConfig: { apiKey: "your_api_key" },
});
return (
<div style={{ position: "relative" }}>
<textarea {...inputProps} />
{/* `mode` styles + themes the dropdown on its own — no wrapper needed */}
<AIAutocompleteDropdown {...dropdownProps} mode="auto" />
</div>
);
}Always call
reset()after handling submit. It clears the input and rotates the per-sessionsession_id. This applies whether the submit was triggered by Enter, a custom button, or any other mechanism.
Standalone dropdown styling (mode)
<AIAutocompleteDropdown /> reads its design tokens + color mode from a .magicx-aia ancestor. When you render it on its own (Tier 2/3), pass mode ("light" | "dark" | "auto") and it self-scopes those tokens to its own root — no .magicx-aia wrapper required. "auto" follows prefers-color-scheme. Leave mode unset only when the dropdown is already inside a .magicx-aia element (e.g. Tier 1). optionsPosition flows through dropdownProps, so above/below placement works with no extra CSS.
Custom / rich-text inputs
inputProps is shaped for a <textarea>. For a contentEditable or rich-text editor (Tiptap, ProseMirror, Lexical…), don't spread inputProps — call the actions directly: handleTextChange(text) on every edit, setFocused(bool) on focus/blur, handleKeyDown(event) for Arrow/Enter/Tab/Escape while the dropdown is open, and handleCaretMove(offset) so arrow keys can move into the dropdown.
Tier 3: Headless
Skip <AIAutocompleteDropdown /> and render the suggestions UI yourself. dropdownProps carries the data + actions — the active suggestion's options, the highlighted index, isOpen, and onSelect / onHighlight:
Datepicker in a custom UI. For a date parameter,
dropdownProps.formatTypeis"date"andsuggestions[0].optionsholds that month's day cells. Each cell'stextis the date it commits ("March 23", or"Tuesday"inside the next week), so rendering them as a plain list already works —onSelectbehaves exactly as it does for an option. To draw an actual calendar, readdateViewfor the month on show,cellDay(option)for the number to paint, and callonPreviousMonth/onNextMonthto page. Cells that pad the start and end of the month haveis_tappable: false. For a range parameterformatTypeis"date-range"and the sameonSelectfires twice: the first records the start (dropdownProps.dateRangeStartholds it, and nothing is committed yet), the second commits the span.dateCellMarksandvisibleDateRangeare exported to paint the two ends and the band between them the way the built-in calendar does.
Option icons. An option may carry an icon:
icon_svgis inline SVG markup andiconits name. The built-in dropdown draws the SVG before the option's text, in the text color, and hides it while the row shows its loading skeleton. When rendering options yourself, passicon_svgthrough the exportedsanitizeOptionIconSvgbefore inserting it — it reduces the markup to plain vector drawing and returnsnullfor anything else — and useoptionLabel(option)for the text — it is the option'stextalone;iconis the icon's name and is never shown as text, even whenicon_svgis missing or fails to draw. A picked option'sicon/icon_svgare copied onto its completed parameter, and the chip in the input draws the glyph before its text. Both surfaces can be switched off:showOptionIcons: falserenders option rows as text only andshowChipIcons: falserenders chips as text only; either way the icon element is omitted rather than hidden.
Option pictures and counts. An option may also carry
image_url— a picture of the best-matching catalog item offering it — anditem_count, how many items offer it (each is present only when the server includes it; when it includes counts, the options also arrive ordered by them, most items first; a starting state carries pictures but no counts). The built-in dropdown draws the picture as a square thumbnail before the option's text (in place of itsicon_svg, so a row never leads with two visuals) and the count as a muted label at the row's trailing edge ("12 items"); a skeleton row keeps the tile's placeholder fill and hides the count. The unit word after the number is a token: set--aia-option-count-unit: "sneakers"to name what is being counted,--aia-option-count-unit-one: "sneaker"for a count of 1 (it falls back to the plural token), or--aia-option-count-unit: ""for the number alone. When rendering options yourself, passimage_urlthrough the exportedoptionImageSrcbefore using it as an<img src>— it returns the URL only when it ishttp(s)andnullotherwise — andformatOptionCount(option.item_count)gives the number the built-in rows print (nullwhen there is no count);optionCountLabel(count, unit?)appends a unit for a dropdown that prints the word itself ("item"/"items"by default; one word, a{ one, other }pair, or""for none). Both surfaces can be switched off:showOptionImages: falserenders rows without pictures (an option's icon then draws instead) andshowOptionCounts: falsehides the counts. A picked option'simage_urlis copied onto its completed parameter too, and the chip in the input draws it as a small rounded tile before its text, in place of the icon;showChipImages: falserenders chips without pictures (the icon then draws instead). The tile is sized by--aia-option-image-size(40px), rounded by--aia-option-image-radius(12px), spaced by--aia-option-image-gap(12px) and filled by--aia-option-image-bg(default:--aia-skeleton-bg) while its picture loads; in a grid with pictures, an icon row draws its glyph at--aia-option-tile-icon-size(1.5em) so it holds the picture column; the count reads--aia-option-count-font-size(12px),--aia-option-count-colorand--aia-option-count-opacity(0.6); the chip's tile reads--aia-chip-image-size(1.25em of the chip's font),--aia-chip-image-radius(4px) and--aia-chip-image-bg(default:--aia-skeleton-bg).
import { useAIAutocomplete } from "@magicx-eng/ai-autocomplete-react";
function App() {
const { inputProps, dropdownProps, reset } = useAIAutocomplete({
apiConfig: { apiKey: "your_api_key" },
});
const { suggestions, activeIndex, isOpen, onSelect, onHighlight } = dropdownProps;
const options = suggestions[0]?.options ?? [];
return (
<div style={{ position: "relative" }}>
<textarea {...inputProps} />
{isOpen && (
<ul className="my-dropdown" role="listbox">
{options.map((option, i) => (
<li
key={option.text}
role="option"
aria-selected={i === activeIndex}
onMouseEnter={() => onHighlight(i)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus in the input
onSelect(option);
}}
>
{option.text}
</li>
))}
</ul>
)}
</div>
);
}API Reference
<AIAutocomplete />
| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | (result: AutocompleteResult) => void | required | Called on Enter or submit button. |
| onResult? | (result: AutocompleteResult) => void | — | Called after every successful round-trip with the structured query as it now stands — see Reading the query as it's built. |
| onError? | (error: Error) => void | — | Called when a fetch fails. |
| apiConfig? | APIConfig | — | Runtime API configuration (see below). |
| visitorId? | string \| (() => string \| undefined) | — | Stable UUID v4 for this visitor, kept across page loads, or a function returning it. See Visitor id and submits. |
| additionalContext? | Record<string, unknown> | — | Optional user context. Include whatever you know about the user (a profile, preferences, workspace, anything) to personalize suggested parameters and options to them. |
| optionOverrides? | OptionOverrides | — | Supply the options for a parameter yourself, per suggestion type — fixed, computed, or fetched as the user types. See Option Overrides. |
| maskCompletedText? | boolean | false | When true, omits completed params' literal text from API requests (for masking PII/sensitive values from the server). |
| className? | string | — | CSS class applied to the container. |
| columns? | number | 2 | Number of columns in the dropdown grid. |
| pillPlacement? | "inline" \| "dropdown" \| "hidden" | "dropdown" | Where to render unfilled pills. "hidden" hides pills entirely. |
| mode? | "light" \| "dark" \| "auto" | "auto" | Color mode. "auto" follows prefers-color-scheme. |
| optionsPosition? | "above" \| "below" | "below" | Where the dropdown opens relative to the input. |
| animations? | boolean | true | Enable/disable all SDK animations (press + shimmer, the typed-in starting-state placeholder, the option rows' staggered entrance, and the "more below" arrow's slide). prefers-reduced-motion also switches the last three off. |
| dropdownTrigger? | "auto" \| "manual" \| "hidden" | "auto" | When the dropdown appears. "auto" = when options available. "manual" = only on pill tap, closes after selection. "hidden" = never shows. |
| closeDropdownOnBlur? | boolean | true | When true, the dropdown closes if the input loses focus. Set to false to keep it open whenever options are available, regardless of focus. |
| showNonTappableOptions? | boolean | true | When true, non-tappable options are rendered alongside tappable ones in the dropdown. Set to false to hide non-tappable options entirely. |
| showSkipButton? | boolean | true | When true, the dropdown's pill bar ends in a small "skip" button that dismisses the active pill — same action as pressing → at the end of the input. It sits top-right when the dropdown opens below the input, bottom-right when optionsPosition is "above", and appears once the input has text (alongside the footer's "→ to skip" hint). Set to false to hide it. |
| showOptionIcons? | boolean | true | When true, an option that carries an icon_svg draws its icon before its text in the dropdown. Set to false to render option rows as text only. |
| showChipIcons? | boolean | true | When true, a completed chip in the input draws the icon of the option that answered it. Set to false to render chips as text only. |
| showChipImages? | boolean | true | When true, a completed chip in the input draws the picture (image_url) of the option that answered it, in place of its icon. Set to false to render chips without pictures. |
| showOptionImages? | boolean | true | When true, an option that carries an image_url draws it as a square thumbnail before its text in the dropdown, in place of its icon. Set to false to render option rows without pictures. |
| showOptionCounts? | boolean | true | When true, an option that carries an item_count shows it as a muted label at the row's trailing edge ("12 items"). Set to false to hide the counts. |
| autoFocus? | boolean | true | Focus the input on mount. Set to false to leave focus to the consumer. |
| onFocus? | () => void | — | Called when the input gains focus. |
| onBlur? | () => void | — | Called when the input loses focus. |
| value? | string | — | Controlled text value. |
| completedParams? | CompletedParamState[] | — | Controlled completed params. |
| onChange? | (value: string) => void | — | Called when text changes (controlled mode). |
| onParamsChange? | (params: CompletedParamState[]) => void | — | Called when params change (controlled mode). |
| showProducts? | boolean | true | Render the product strip below the options whenever there are products to show — see Product strip. Set to false to render no strip. |
| productsLayout? | "row" \| "list" | "row" | How the strip lays its cards out: a horizontally scrolling row, or the same cards stacked as full-width rows — see Product strip. |
| productImageFallback? | "image" \| "initial" | "image" | Fallback for a missing or blank product image URL, or an image load error. "image" shows the placeholder pictogram; "initial" shows the title initial. A working image still takes priority. See Product strip. |
| products? | ProductsConfig | — | Custom source for the product strip — see Your own product search. Omit it and the strip shows the items the /suggest response reports. |
| onProductSelect? | (product: Product) => void | — | Called when a product card is activated. The SDK never navigates. |
| resetOnSubmit? | boolean | true | Clear the input and start a new session after onSubmit runs. Set to false to keep the query in place — e.g. when onSubmit navigates away — and call the handle's reset() yourself. |
| submitButton? | ReactNode | — | Custom submit button. Pass any ReactNode to replace the default arrow button. Pass null to render no button. Clicks bubble up and trigger submit, so consumer-supplied buttons work without re-wiring onClick. |
| ref? | Ref<AIAutocompleteHandle> | — | Imperative handle with focus(), blur(), reset(), and setMode(). |
Product strip
The dropdown renders a horizontal row of product cards below the options grid
whenever it has products to show. By default those are the items the
/suggest response itself reports as matching the query — no configuration,
no extra request: the cards land in the same state update as the suggestions,
so what the strip shows and what the pills offer always describe the same
narrowing.
<AIAutocomplete
onSubmit={handleSubmit}
// Selection emits — the SDK never navigates.
onProductSelect={(product) => product.url && router.push(product.url)}
/>Each server item becomes a Product with id (the item's stable id), title,
price (the item's lowest price, verbatim as the catalog spells it — the
currency is yours to add), vendor, imageUrl, and a url: the item's own
item_url when the uploaded catalog named one (absolute or root-relative;
anything else is ignored), else a relative /products/{handle} — the right
link for a Shopify widget running on the storefront itself — opened on the
variant the query chose (?variant=…, e.g. the blue one for "blue shoes") when
there is one, whose price and image the card then shows. available is carried
over when the server reports it. An item with
neither gets no url: its card is still activatable and still emits
onProductSelect, it just has nothing for cmd-click to open. productFromMatchedItem / productsFromCustomFields are
exported for a hand-rolled strip that wants the same mapping.
Pass showProducts={false} to render no strip at all. The catalog report
(below) stays readable either way.
productsLayout="list" stacks the same cards as full-width rows — thumbnail
on the left, title, vendor and price on the right — for results that read as
a list (courses, listings, documents) rather than products on a shelf. The
column scrolls vertically inside --aia-product-list-max-height (320px by
default); --aia-product-list-media-size (44px) sizes the thumbnail and
--aia-product-list-gap (2px) the space between rows. "row", the default,
is the horizontal strip. Changing the prop re-lays the strip out at once, and
<AIAutocompleteDropdown> takes the same prop in Tier 2.
Product image fallback. A product's image takes priority when its
imageUrl loads successfully. A missing or blank URL, or an image load error,
uses productImageFallback: "image" (the default) shows the existing
placeholder pictogram; "initial" shows the first character of the trimmed,
uppercased title. This is a Unicode grapheme (so a combined character or emoji
stays together), with a Unicode code point fallback in older browsers. An
empty or whitespace-only title shows ?. The fallback works in both row and
list layouts. Initial tiles are decorative (aria-hidden="true"); the card's
visible title supplies its text.
Sold out. A product with available: false shows a "Sold out" badge over
the top-left of its image (in the list layout, a smaller banner across the
bottom of the thumbnail) and its image is desaturated; the card stays
selectable and keeps its link. Absent or true shows no
badge, so leave available out when you don't know. The badge is part of the
card's text, so screen readers announce it. Theme it with
--aia-product-badge-bg, --aia-product-badge-color,
--aia-product-badge-font-size and --aia-product-badge-inset, and the list
banner with --aia-product-badge-list-bg, --aia-product-badge-list-color and
--aia-product-badge-list-font-size.
Pass productImageFallback="initial" to <AIAutocomplete> or
<AIAutocompleteDropdown>. The hook also accepts the option and carries it
through dropdownProps:
// Tier 1
<AIAutocomplete onSubmit={handleSubmit} productImageFallback="initial" />;
// Tier 2
function ProductSearch() {
const { inputProps, dropdownProps } = useAIAutocomplete({
productImageFallback: "initial",
});
return (
<>
<textarea {...inputProps} />
<AIAutocompleteDropdown {...dropdownProps} mode="auto" />
</>
);
}Style the initial tile itself with [data-aia-product-initial]. For
example, wrap the widget in an element with id="catalog-search" and add this
rule to your stylesheet. The existing --aia-product-media-bg variable sets
the background; color, font-size and border-radius style the initial
without additional SDK options.
#catalog-search [data-aia-product-initial] {
--aia-product-media-bg: #eef2ff;
color: #4338ca;
font-size: 22px;
border-radius: 12px;
}The catalog report (customFields)
Every response that read the catalog also says what it did with the query. The
hook returns it as customFields, and it rides on AutocompleteResult as
custom_fields, so onResult on either tier carries it too:
const { customFields } = useAIAutocomplete({ ... });
// Or, on Tier 1:
<AIAutocomplete onResult={(result) => setReport(result.custom_fields)} />
{report && <p>{report.items.total} results</p>}
{report?.dropped_filters.length > 0 && (
// e.g. { parameter: "Color", value: "Chartreuse", op: "eq" } — the shopper asked
// for a colour no item carries, so the results ignore it. Say so.
<p>No match for {report.dropped_filters.map((f) => f.value).join(", ")}</p>
)}applied_filters/dropped_filters— the constraints the server read out of the query as{ parameter, value, op }(opis one ofeq,ne,lt,lte,gt,gte), split into the ones the catalog answered and the ones it could not (a parameter it does not have, a bound that is not a number, a value no item carries). The items answer the applied set, so the dropped list is what tells "87 black t-shirts" from "no black item, so here are t-shirts". Both are always arrays.items.total— how many items the applied filters left standing. It also moves with the query's own words, so it drops as a typed word starts matching a title.items.matchedis the list the strip renders, capped by the server; it is empty (with a non-zerototal) when nothing narrowed the catalog, where listing arbitrary items as matches would mislead.null(nocustom_fieldskey on the result) means the response carried no report — no catalog was read for it (a product with no catalog, the starting state, a response with no option-bearing parameter to offer). That is "no news", not an empty catalog. The strip clears on such a response rather than keeping an older query's items.
Your own product search (products)
To source the cards from your own search instead — a platform with its own
endpoint and response shape — pass products. The SDK owns the UI; you own
only the fetching and the mapping. The server's items are then not rendered
(they stay readable on customFields).
import { AIAutocomplete, type Product } from "@magicx-eng/ai-autocomplete-react";
const products = {
// You own the request entirely — auth headers, GraphQL body, locale
// prefixes. Honour the signal: the SDK aborts it as soon as a newer query
// supersedes this one.
fetch: (query: string, signal: AbortSignal) =>
fetch(`/api/products?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),
// Pure mapping, kept out of `fetch` so you can unit-test it without a
// network.
transform: (raw: unknown): Product[] =>
(raw as ApiResponse).items.map((item) => ({
id: item.id,
title: item.title,
url: item.url,
imageUrl: item.image?.src ?? null, // null uses the configured productImageFallback
price: formatMoney(item.price), // you know the currency
vendor: item.brand,
})),
limit: 8, // applied by the SDK, after transform
};
<AIAutocomplete
onSubmit={handleSubmit}
products={products}
onProductSelect={(product) => product.url && router.push(product.url)}
/>;Product is exactly seven fields; only id and title are required:
type Product = {
id: string;
title: string;
url?: string; // absent = a card with no link (still activatable)
imageUrl?: string | null;
price?: string;
vendor?: string;
available?: boolean; // false shows a "Sold out" badge
};What the SDK guarantees
- One fetch cadence. The product search rides the same debounce, the same
AbortControllerand the same version guard as/suggest— there is no second timer to drift out of step. - Empty queries never reach you. On mount and after
reset()the strip clears. - Out-of-order responses are dropped. A slow response for an older query is never rendered over a newer one.
- Failure is silent. A rejected
fetchor a throwingtransformclears the strip, logs once, and leaves the suggestions half untouched — noerrorstate, noonError, no effect onisLoading. - The two halves are independent. The panel opens if either has content,
so products keep it open when option filtering empties the option list.
dropdownTrigger(manual/hidden) still gates the panel as before. - Inline configs are safe.
products={{ fetch, transform }}written inline re-creates the object on every render; the hook forwards through a stable proxy, so only setting or removing the config reaches the core. Two deliberate consequences: swapping one live config for another leaves the previous integration's cards up until the next request replaces them (removeproductsand set it again to clear immediately), and setting a config mid-session shows nothing until the next/suggestrequest fires — the product search rides that one scheduler, and configuring it doesn't itself change the query. Removing the config restores the server's items at once.
Selection and links. Cards are real <a href> elements, so cmd/ctrl-click,
middle-click and "copy link address" behave natively. A plain left click is
intercepted (preventDefault) and emits onProductSelect instead.
product.url is used verbatim — the SDK trusts your transform output and
does not sanitise it, so validate the URL there if the platform response isn't
fully under your control. (Angular additionally runs its own href
sanitiser, so unrecognised schemes like myapp://… are rewritten to
unsafe:… in that package only.)
Accessibility. The strip is a role="group" labelled "Products"; each card
is a role="option" in the tab order, activated with Enter or Space, with a
visible focus ring. Tabbing into the strip does not close the panel. The row
scrolls horizontally by trackpad, wheel, touch and keyboard; the page never
scrolls sideways and the scrollbar chrome is hidden in all engines.
Tier 2 gets the same thing: useAIAutocomplete() takes showProducts,
products, productImageFallback and onProductSelect, and the strip renders inside
<AIAutocompleteDropdown /> via dropdownProps. Tier 3 consumers read
products (and customFields) from the hook and call
selectProduct(product) from their own cards.
Custom submit button
<AIAutocomplete
onSubmit={handleSubmit}
submitButton={<button className="my-button">Go</button>}
/>
// Or hide the button entirely:
<AIAutocomplete onSubmit={handleSubmit} submitButton={null} />APIConfig
A discriminated union: APIKeyConfig | AccessTokenConfig.
API Key Mode (default)
{ apiKey: "your_api_key", authScheme: "Bearer", endpoint: "/ac/suggest" }| Field | Type | Description |
|---|---|---|
| type? | "apiKey" | Optional discriminator. Default when omitted. |
| apiKey? | string | API key for Authorization header. |
| authScheme? | "Bearer" \| "Basic" | Auth header scheme. Default: "Bearer". |
| endpoint? | string | Full URL for the suggest endpoint. Default: "https://api.ai-autocomplete.com/api/suggest". |
| appIdentifier? | string | Value for the X-App-Identifier header. |
| headers? | Record<string, string> | Additional headers merged into every request. |
Access Token Mode
<AIAutocomplete
apiConfig={{
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 };
},
}}
onSubmit={handleSubmit}
/>| Field | Type | Description |
|---|---|---|
| type | "accessToken" | Required discriminator. |
| getAccessToken | () => Promise<AccessTokenResult> | Required. Called when the SDK needs a token. |
| accessToken? | string | Initial token. Avoids one round-trip on mount. |
| endpoint? | string | Suggest endpoint URL. Default: "https://api.ai-autocomplete.com/api/suggest". |
| appIdentifier? | string | Value for the X-App-Identifier header. |
| headers? | Record<string, string> | Additional headers merged into every request. |
The SDK handles token refresh transparently: 401 → getAccessToken → retry (once). Concurrent 401s share a single refresh. Tokens refresh proactively 30s before expiresAt.
useAIAutocomplete(options)
The headless hook for Tier 2 and Tier 3. Accepts the same options as <AIAutocomplete /> except for the component-only rendering props: className, ref, pillPlacement, mode, animations, and autoFocus (those belong to the wrapping component — the hook doesn't own the input element). productImageFallback is also accepted and forwarded through dropdownProps. optionsPosition is accepted: it sets the arrow-key direction and flows through dropdownProps to the dropdown. onFocus and onBlur are forwarded and fire whenever the consumer-owned textarea's focus changes (they're driven by inputProps.onFocus / inputProps.onBlur). onResult is accepted too and fires after every successful round-trip — see Reading the query as it's built.
Return Value
State
| Field | Type | Description |
|---|---|---|
| completedParams | CompletedParamState[] | Filled parameters. |
| skippedParams | SkippedParamState[] | Suggestions the user dismissed with →. Nothing renders them — pass them to buildSubmitResult for a hand-rolled submit. |
| identifiedParams | IdentifiedParamState[] | Parameters the server recognised in the user's own words. Already rendered through segments; pass them to buildSubmitResult for a hand-rolled submit so it reports the same identified_params the SDK's own results do. |
| suggestionPills | Suggestion[] | Unfilled suggestions (pills). First item is the active pill. |
| segments | Segment[] | Input text split into typed text vs completed params — completed segments render as bold <strong> runs inside the editor. |
| newParamId | string \| null | ID of the most recently added param (for shimmer animation). |
| suggestions | Suggestion[] | All suggestions from server (including placeholder type). |
| activeIndex | number | Highlighted option index. -1 = none. |
| isLoading | boolean | True when the dropdown should render its loading skeleton — a fetch is in flight, no option-selection animation is playing, and the user is not in re-edit mode (where cached options stay visible). |
| isReady | boolean | Server indicates query is complete. |
| isDropdownOpen | boolean | Whether the dropdown should be visible. Drive your own dropdown's visibility with this in Tier 3. |
| placeholderText | string | Suggested placeholder text for the current step. |
| error | Error \| null | Last fetch error. |
| products | Product[] | What the product strip shows: the items the latest response reported as matching the query, or the results of a custom products search. Already spread into dropdownProps — read it directly only if you render your own strip. |
| customFields | CustomFieldsReport \| null | The catalog report the latest response carried — applied and dropped filters, and the matching items' count and list — or null when it carried none. See The catalog report. |
Actions
| Field | Type | Description |
|---|---|---|
| setActivePill | (index: number) => void | Move pill at index to front (active). |
| skipActivePill | () => void | Skip the active pill — same action as → / the dropdown's skip button. Records it in skippedParams; no-ops during re-edit and the post-selection window. Already wired into dropdownProps.onSkip; call it directly for a custom skip affordance (e.g. with showSkipButton: false). Also exposed on the Tier 1 imperative handle. |
| removeLastParam | () => void | Remove the last completed param from state. The text stays in the input as plain text. |
| clearNewParamId | () => void | Clear shimmer animation state. |
| reset | () => void | Clear all state, re-fetch, and start a new session (rotates session_id). Call this after handling submit. |
| recordSubmit | (result: AutocompleteResult) => void | Record a submit from a button you render. Enter is recorded for you; call this once per button submit. |
| selectProduct | (product: Product) => void | Announce a product selection (fires onProductSelect). The built-in dropdown calls this for you; hand-rolled strips call it themselves. Never navigates. |
Input forwarding (custom inputs) — call these instead of spreading inputProps when your input isn't a <textarea> (contentEditable / rich-text editors):
| Field | Type | Description |
|---|---|---|
| handleTextChange | (text: string) => void | Forward the input's current plain text on every edit. |
| handleKeyDown | (e: KeyboardEvent) => void | Forward a key event so the dropdown can handle Arrow/Enter/Tab/Escape while open. Check event.defaultPrevented to see if it was consumed. |
| setFocused | (focused: boolean) => void | Notify the engine the input gained or lost focus. |
| handleCaretMove | (offset: number) => void | Report the caret position (plain-text offset) so arrow keys can move into the dropdown. |
Spread Props
| Field | Type | Description |
|---|---|---|
| inputProps | object | Spread onto a <textarea>. Includes value, placeholder, onChange, onKeyDown, and ARIA attributes. |
| dropdownProps | AIAutocompleteDropdownProps | Spread onto <AIAutocompleteDropdown />. Carries the options, activeIndex, onSelect / onHighlight, pills, open state, optionsPosition, and the product strip's products / productImageFallback / onProductSelect / onProductFocusChange — read these directly to render your own dropdown in Tier 3. |
<AIAutocompleteDropdown />
The dropdown component for Tier 2. Spread dropdownProps from the hook (and add mode when rendering standalone).
| Prop | Type | Description |
|---|---|---|
| suggestions | Suggestion[] | Suggestions to display. |
| activeIndex | number | Highlighted option index. |
| onSelect | (option: SuggestionOption) => void | Called when an option is selected. |
| onHighlight | (index: number) => void | Called on mouse hover. |
| isOpen | boolean | Whether the dropdown is visible. |
| id | string | Listbox ID for ARIA. |
| mode? | "light" \| "dark" \| "auto" | Color mode for a standalone dropdown — self-scopes the SDK tokens so no .magicx-aia wrapper is needed. Leave unset when nested inside a .magicx-aia ancestor (e.g. Tier 1). |
| optionsPosition? | "above" \| "below" | Where the dropdown opens. Provided via dropdownProps; "above" reverses the internal layout. Default: "below". |
| className? | string | CSS class applied to the dropdown. |
| pills? | Suggestion[] | Pills to render inside the dropdown. |
| onPillClick? | (index: number) => void | Called when a pill is clicked. |
| showPills? | boolean | Whether to render pills. Default: true. |
| products? | Product[] | Product cards to render below the options grid. Empty renders no strip. |
| productImageFallback? | "image" \| "initial" | Default: "image". Fallback for a missing or blank product image URL, or an image load error. "image" shows the placeholder pictogram; "initial" shows the title initial. A working image still takes priority. See Product strip. Forwarded by the hook in dropdownProps. |
| onProductSelect? | (product: Product) => void | Called when a card is activated. |
| onProductFocusChange? | (focused: boolean) => void | Called when focus enters/leaves the strip. Wired to setFocused by dropdownProps so a tabbed-to card doesn't close the panel. |
| isLoading? | boolean | When true, the dropdown renders its pills + options as a skeleton: text masked, layout (count and widths) preserved, shimmer pulse animating. Falls back to a generic 3-bar placeholder when no pills/options are cached. |
AutocompleteResult
The structured query as the SDK currently understands it. The same object is passed to onSubmit when the user submits and to onResult after every successful round-trip:
| Field | Type | Description |
|---|---|---|
| query | string | Plain text as the user sees it, trimmed. |
| raw_query | string | query with each completed param replaced by its placeholder token (e.g. "Create a {{TASK_1}}"). |
| completed_params | CompletedParam[] | Filled parameter values in query order, followed by any the user skipped (see below). |
| identified_params | IdentifiedParam[] | Parameters the server recognised in the user's own words, as { parameter, text } — e.g. { parameter: "due_date", text: "Friday" } when the user typed "by Friday" instead of picking a date. They are not tokenized in raw_query, and they are tentative: each response replaces the set, and an entry drops out as soon as its text is edited away. |
| is_ready | boolean | Whether the server considers the query complete enough to act on. |
| custom_fields? | CustomFieldsReport | What catalog grounding did for the latest applied response, when it read a catalog at all — see Product strip. applied_filters and dropped_filters list the query's constraints as { parameter, value, op }; items.total is how many items the applied ones left standing and items.matched the ones the strip renders. Absent (no key) when the response carried no report. |
Reading the query as it's built
You don't have to wait for submit. onResult fires after every successful round-trip with the result as it now stands, so you can mirror the structured query live — a preview panel, a draft saved as the user goes, analytics on how far they got. It works on both tiers:
<AIAutocomplete
onResult={(result) => setPreview(result)}
onSubmit={(result) => run(result)}
/>
// Tier 2 / 3
const ac = useAIAutocomplete({
onResult: (result) => saveDraft(result),
onSubmit: (result) => run(result),
});What "every successful round-trip" means in practice:
- It fires once per response the SDK applied — including the very first request on mount (an empty result) and after a response that promoted text the user had already typed into a completed param.
- It does not fire for a request that failed (
onErrordoes), was cancelled, or was superseded by a newer one before it returned. - Keystrokes that haven't been sent yet don't fire it. Typing is debounced and only reaches the server once at least two new characters have been added, so
onResultlags the input by a beat. - An inline arrow function is fine — the latest one you passed is always the one called.
- A submit is the last result you saw plus whatever the user typed since. Both are built by the same exported
buildSubmitResult, so the shapes never drift.
Skipped parameters
Pressing → at the end of the input dismisses the active pill. The dismissal is reported to the server — and included in completed_params here — as an entry with no placeholder and the sentinel text "skipped":
{ placeholder: "", parameter: "goal", text: "skipped", kind: null }Tier 2 consumers who build their own submit payload get the raw skips from the hook as skippedParams, and can fold them in the same way with the exported buildSubmitResult(text, completedParams, skippedParams, { identifiedParams, isReady }) — the last argument carries the hook's identifiedParams and isReady so a hand-rolled submit reports the same identified_params and is_ready the SDK's own results do.
Reading
skippedParamsdirectly: the array is append-only untilreset(). The "drop a skip whose parameter got filled" rule is applied when the payload is built, not by pruning the array — so if the user skipsgoaland later fills one, the raw array still holds thegoalentry. That's deliberate: the filter self-heals if they then delete that param's text, where pruning would discard the signal for good.buildSubmitResultapplies the rule for you; to apply it elsewhere (say, a "you skipped X" badge), use the exportedwithSkippedParams(completedParams, skippedParams).
CSS Customization
Styles are auto-injected at runtime — no CSS import needed. Built-in light and dark defaults apply automatically based on mode.
Every class name the package emits is namespaced — aia-* for its own (hashed CSS-module) classes and the layout primitives, magicx-aia-* for the classes it shares with the vanilla core — so nothing it renders can match a .container, .pill or .dropdown rule in your own stylesheet, and none of its rules can reach your elements. Use the --aia-* variables and data-aia-* selector hooks below for overrides.
Page CSS stops at the widget's edge. Everything inside the input wrapper ([data-aia-input-wrapper]) and the dropdown ([data-aia-dropdown]) is reset to browser defaults before the SDK's own styles apply, so element and universal rules in your stylesheet — button { … }, a { … }, * { … }, a CSS reset, a framework's preflight — never reach it, and inherited typography adjustments from the page (line-height, letter-spacing, text-transform, text-align, …) are pinned at those two roots. Font family, size, weight and text colour still inherit from your page; a submit button you provide is left to your own stylesheet; and nothing the SDK ships styles elements outside its own subtree. The reset sits at specificity (0,0,5), below any rule that names a class, attribute or id — your overrides on the data-aia-* hooks work exactly as before, and so does a page rule like .sidebar button { … }, which names a class: scope rules like that to your own markup.
CSS Variables
Override on the container (via className). The variables are declared with :where() (zero specificity), so a value you set on the container always wins.
| Variable | Light | 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 | Highlighted option background |
| --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. Solid + full-opacity at rest, even when the input is empty/disabled. |
| --aia-submit-color | #ffffff | #000000 | Submit button arrow color |
| --aia-submit-bg-disabled | --aia-submit-bg | --aia-submit-bg | Submit background when disabled (empty input). Defaults to the enabled background so themed colors aren't washed out. Set this for a faded rest state. |
| --aia-submit-color-disabled | --aia-submit-color | --aia-submit-color | Submit arrow color when disabled. Defaults to the enabled color. |
| --aia-border | rgba(17, 24, 39, 0.14) | #505050 | Input container border color |
| --aia-shadow | none | none | Input container elevation (box-shadow). Flat by default; set it to add one |
| --aia-dropdown-offset | 13px | 13px | Gap between the input box and the dropdown (the dropdown's margin toward the input). |
| --aia-content-inset-left | 16px | 16px | Left inset of the SDK's content: the input wrapper's left padding, and the line the dropdown's content (parameter label, option text, product strip) starts on. Set it when you pad the field to clear a leading icon of your own, so the dropdown's content stays aligned under the typed text. Practical minimum is 10px (the option text's own inset); both surfaces floor it there. |
| --aia-footer-gap | 12px | 12px | Breathing room above the dropdown footer (badge / keyboard hints), additive to the dropdown's 8px section gap. |
| --aia-footer-chip-bg | --aia-surface at 65% | --aia-surface at 65% | Fill behind the footer's keyboard hint and AI-Autocomplete badge. The option list scrolls under the footer, so this keeps both legible over the row passing behind them. transparent on the glass surface. |
| --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) | No longer used: the option list hides its scrollbar in every browser. Kept so existing overrides don't break. |
| --aia-streak-rgb | 99, 102, 241 | 255, 255, 255 | Comma-separated RGB triplet tinting the datepicker's pressed cell, and its selected cell when --aia-date-selected-bg is unset. The today ring uses --aia-date-today-ring, not this. |
| --aia-product-card-width | 116px | 116px | Width of a product card in the strip. The media tile is square, so this also sets its height. |
| --aia-product-gap | 8px | 8px | Gap between product cards. |
| --aia-product-bg | transparent | transparent | Product card background. |
| --aia-product-bg-active | --aia-option-bg | --aia-option-bg | Product card background on hover. |
| --aia-product-media-bg | --aia-skeleton-bg | --aia-skeleton-bg | Fill behind product images and both fallback tiles (placeholder pictogram or title initial), used when an image URL is missing, blank or fails to load. |
| --aia-scroll-arrow-color | --aia-option-color | --aia-option-color | Chevron color of the arrow (--aia-scroll-arrow-color-hover on hover). |
| --aia-placeholder-fade | 120ms | 120ms | Fade-out of the outgoing placeholder phrase when the starting-state placeholder changes (the incoming one types itself in). |
| --aia-product-placeholder-image | storefront pictogram | storefront pictogram | The pictogram used by productImageFallback: "image" (the default) when an image URL is missing, blank or fails to load. Defaults to a two-tone storefront inlined as a data URI. Set any url(...) to swap it; it is centered and contained over --aia-product-media-bg. Does not affect "initial" tiles. |
| --aia-product-title-color | --aia-option-color-selected | --aia-option-color-selected | Product title text. Follows the option colors by default, so theming the panel moves suggestions and products together. |
| --aia-product-price-color | --aia-option-color-selected | --aia-option-color-selected | Product price text. |
| --aia-product-vendor-color | --aia-option-color | --aia-option-color | Product vendor line. |
| --aia-product-focus-ring | --aia-option-color-selected | --aia-option-color-selected | Focus ring drawn on a keyboard-focused card. |
| --aia-products-label-color | --aia-option-color | --aia-option-color | "Products" section label. |
| --aia-product-title-font-size | 12px | 12px | Product title font size. |
| --aia-product-price-font-size | 11px | 11px | Product price font size. |
| --aia-product-vendor-font-size | 10px | 10px | Product vendor font size. |
| --aia-products-label-font-size | 11px | 11px | Section label font size. |
| --aia-date-panel-width | 312px | 312px | Max width of the whole dropdown panel while the calendar is up — the panel narrows to calendar size. |
| --aia-date-cell-size | 26px | 26px | Size of a day's square — the box that carries the highlight, the today ring and the selected fill. |
| --aia-date-row-height | 28px | 28px | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
| --aia-date-cell-font-size | 13px | 13px | Day-number font size. |
| --aia-date-month-font-size | 14px | 14px | Font size of the "March 2026" header. |
| --aia-date-weekday-font-size | 11px | 11px | Font size of the S/M/T/W/T/F/S column letters. |
| --aia-date-today-ring | --aia-option-color | --aia-option-color | Outline drawn around today's date. |
| --aia-date-selected-bg | white at 12% | white at 12% | Fill behind the date a re-edited parameter already holds. |
| --aia-date-range-bg | white at 6% | white at 6% | Band across the days between the two ends of a picked date range. The ends themselves use --aia-date-selected-bg. |
| --aia-skeleton-bg | rgba(189, 189, 189, 0.083) | #1a1b1d | Fill color for the loading skeleton bars and the masked text in cached pills/options, and of the option and chip image tiles while their picture loads (unless --aia-option-image-bg / --aia-chip-image-bg are set). |
Legacy --aia-color-* variables are still supported as fallbacks.
Per-mode Overrides
.my-autocomplete[data-mode="light"] {
--aia-pill-bg: #e2e8f0;
}
.my-autocomplete[data-mode="dark"] {
--aia-pill-bg: #334155;
}Selector Hooks
For styling beyond the CSS variables, target these stable data-aia-* attributes (CSS-module class hashes are not part of the public API):
| Attribute | Element |
|---|---|
| [data-aia-input-wrapper] | Wrapper around the editor area and the submit button — one of the two roots the SDK isolates from page CSS (the dropdown is the other) |
| [data-aia-editor] | Editor area wrapping the contentEditable + inline pill list |
| [data-aia-input] | The Tier 1 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-pill-scroll] | Scrollable pill region inside the bar — carries the horizontal scroll and right-edge fade mask |
| [data-aia-skip] | The pill bar's trailing "skip" button. Tune via --aia-skip-font-size / --aia-skip-color / --aia-skip-color-hover / --aia-skip-hover-bg |
| [data-aia-option] | Each suggestion option |
| [data-aia-scroll-arrow] | The "more below" arrow on the dropdown — carries data-aia-visible while shown |
| [data-aia-dropdown] | The dropdown root (listbox). Carries data-aia-has-products while the product strip has cards. |
| [data-aia-datepicker] | The calendar, rendered in place of the option list for a date parameter |
| [data-aia-date-month] | The "March 2026" header label |
| [data-aia-date-prev] / [data-aia-date-next] | The month arrows |
| [data-aia-date-grid] | The 7-column grid of day cells |
| [data-aia-date-cell] | Each day cell. Also carries [data-aia-option], so option-level styling applies to both bodies |
| [data-aia-products] | Product strip section (label + row) |
| [data-aia-products-row] | The horizontally scrolling row of cards |
| [data-aia-product] | Each product card |
| [data-aia-product-placeholder] | Media tile showing the placeholder pictogram (productImageFallback: "image") after a missing/blank URL or load error. The existing hook is unchanged. |
| [data-aia-product-initial] | The media tile itself when it shows the title initial (productImageFallback: "initial"). Decorative (aria-hidden="true"); target it to style the background, color, font size and border radius. |
Completed params render as inline <strong> elements inside the editor. Override their weight with [data-aia-input] strong { font-weight: 700; } — a selector that names the editor wins without !important, while a bare strong { … } page rule is kept out by the isolation reset.
/* 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 mount (or the last reset()) until the next reset(). All requests in one session share the same session_id; calling reset() starts a new one.
The contract is simple: after the user submits the query, call reset(). That clears the input and rotates session_id so the next session begins clean.
Why it matters: suggestions get sharper as a query develops. Each one takes account of what the user has already answered, so the parameters offered late in a query are shaped by the choices made early in it — that is what makes the experience feel guided rather than like a static list.
session_idis what ties those requests together into one query.So
reset()is not bookkeeping: it is how you say "that query is finished". Skip it and the next query is treated as a continuation of the last one, and its suggestions keep being shaped by answers the user has already moved on from — the failure is quiet, and shows up as steadily less relevant options rather than as an error.
- Tier 1
<AIAutocomplete />does this automatically — it callsreset()for you afteronSubmitreturns, for both Enter-key and built-in-button submits. - Tier 2 & 3
useAIAutocomplete()
