Autocomplete
A free-text input with a suggestion list: as the user types, Autocomplete.Content floats a filtered list of suggestions beneath the field. Unlike Combobox, the committed value is the typed text — the suggestions complete and accelerate free entry rather than constraining it to a fixed option set.
Basic
Autocomplete.Root owns the state and the items; Autocomplete.Input is the field, and Autocomplete.Content floats the popup as the user types — an Autocomplete.List mapping each item to an Autocomplete.Item, with an Autocomplete.Empty line for when nothing matches. Wrap the input in an Autocomplete.InputGroup to seat a leading Autocomplete.Icon and a trailing Autocomplete.Clear (shown only while the field holds a value, refocusing the input on click) inside the field. The field follows the rest of the field family — a filled surface, a calm ring-less focus that shifts only the border, and a subtle rest shadow.
"use client";
import { Autocomplete } from "@stridge/noctis";
const FRUITS = ["Apple", "Apricot", "Banana", "Blackberry", "Blueberry", "Cherry", "Grape", "Mango", "Orange", "Peach"];
export default function AutocompleteBasic() {
return (
<div className="w-72">
<Autocomplete.Root items={FRUITS}>
<Autocomplete.InputGroup>
<Autocomplete.Icon />
<Autocomplete.Input aria-label="Fruit" placeholder="Search a fruit…" />
<Autocomplete.Clear />
</Autocomplete.InputGroup>
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No fruit matches your search.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Sizes
The root's size — md (the default) or lg — sets the field's control height and the popup's row density together, so a large field opens a large-density popup. Set it once on Autocomplete.Root.
"use client";
import { Autocomplete, type AutocompleteSize } from "@stridge/noctis";
const TAGS = ["design", "engineering", "marketing", "operations", "product", "research", "sales", "support"];
const SIZES: AutocompleteSize[] = ["md", "lg"];
export default function AutocompleteSizes() {
return (
<div className="flex w-72 flex-col gap-4">
{SIZES.map((size) => (
<Autocomplete.Root key={size} items={TAGS} size={size}>
<Autocomplete.Input aria-label={`Tag (${size})`} placeholder={`Search tags — ${size}`} />
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No tags found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
))}
</div>
);
}
With icons
Give each Autocomplete.Item a leading icon to label the suggestion's kind. The glyph sits quieter than the label and the row lights up with a neutral highlight on hover or keyboard focus — the accent stays a signal, never a hover fill.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { FileCode, FileImage, FileText, FileType, Film, Music } from "lucide-react";
const FILES = [
{ value: "index.tsx", icon: FileCode },
{ value: "README.md", icon: FileText },
{ value: "logo.svg", icon: FileImage },
{ value: "theme.css", icon: FileType },
{ value: "intro.mp4", icon: Film },
{ value: "chime.mp3", icon: Music },
];
const VALUES = FILES.map((f) => f.value);
const ICON_BY_VALUE = new Map(FILES.map((f) => [f.value, f.icon]));
export default function AutocompleteWithIcons() {
return (
<div className="w-72">
<Autocomplete.Root items={VALUES}>
<Autocomplete.Input aria-label="File" placeholder="Jump to a file…" />
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item} icon={ICON_BY_VALUE.get(item)}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No files found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Match highlighting
Bold the part of each suggestion that matches the typed query with Autocomplete.Highlight — pass it the row's text and the current query. The match is weight-and-foreground only (no highlight background, never the accent) so it reads clearly while the list stays quiet — the signature autocomplete moment.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { useState } from "react";
const FRAMEWORKS = ["Next.js", "Remix", "SvelteKit", "Nuxt", "Astro", "SolidStart", "Qwik City", "Gatsby"];
export default function AutocompleteMatchHighlight() {
const [query, setQuery] = useState("");
return (
<div className="w-72">
<Autocomplete.Root items={FRAMEWORKS} onValueChange={setQuery}>
<Autocomplete.Input aria-label="Framework" placeholder="Search frameworks…" />
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
<Autocomplete.Highlight text={item} query={query} />
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No framework found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Async loading
Autocomplete from a server: set loading on Autocomplete.Root while a request is in flight — render an Autocomplete.Loading in the InputGroup for the trailing spinner (which also sets aria-busy on the field) — and render an Autocomplete.Status inside the Content so a screen reader hears the "Searching…" state and the results count politely. Disable the built-in filter (filter={null}) when you filter on the server, and debounce the input (keep predictions under ~1s). When nothing matches, the Empty interpolates the query.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { useEffect, useState } from "react";
const COUNTRIES = [
"Argentina",
"Australia",
"Brazil",
"Canada",
"Denmark",
"Egypt",
"France",
"Germany",
"India",
"Japan",
"Kenya",
"Mexico",
"Norway",
"Portugal",
"Spain",
];
export default function AutocompleteAsync() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<string[]>(COUNTRIES);
const [loading, setLoading] = useState(false);
// Debounce the "fetch" (~half a second; keep predictions under ~1s). Filtering happens off the
// network, so disable Base UI's built-in filter (`filter={null}`) and feed the resolved results in.
useEffect(() => {
setLoading(true);
const id = setTimeout(() => {
const q = query.trim().toLowerCase();
setResults(q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q)) : COUNTRIES);
setLoading(false);
}, 500);
return () => clearTimeout(id);
}, [query]);
return (
<div className="w-72">
<Autocomplete.Root items={results} filter={null} loading={loading} onValueChange={setQuery}>
<Autocomplete.InputGroup>
<Autocomplete.Icon />
<Autocomplete.Input aria-label="Country" placeholder="Search countries…" />
<Autocomplete.Loading />
</Autocomplete.InputGroup>
<Autocomplete.Content>
<Autocomplete.Status>
{loading ? "Searching…" : `${results.length} ${results.length === 1 ? "result" : "results"}`}
</Autocomplete.Status>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No results for “{query}”.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Create on the fly
Because the committed value is free text, the no-match case can become an action: render an Autocomplete.Create row with value set to the typed query when there is no exact match, so committing it accepts the typed value. The row is slightly emphasized with a leading "+" — the differentiator versus Combobox's constrained selection.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { useState } from "react";
const LABELS = ["bug", "documentation", "duplicate", "enhancement", "good first issue", "help wanted", "wontfix"];
export default function AutocompleteCreateOnTheFly() {
const [query, setQuery] = useState("");
const trimmed = query.trim();
const filtered = trimmed ? LABELS.filter((label) => label.toLowerCase().includes(trimmed.toLowerCase())) : LABELS;
const hasExactMatch = LABELS.some((label) => label.toLowerCase() === trimmed.toLowerCase());
// Filtering is done here so the conditional "Create" row can sit alongside the matches, so disable
// Base UI's built-in filter (`filter={null}`) and render the filtered rows directly.
return (
<div className="w-72">
<Autocomplete.Root items={LABELS} filter={null} onValueChange={setQuery}>
<Autocomplete.Input aria-label="Label" placeholder="Find or create a label…" />
<Autocomplete.Content>
<Autocomplete.List>
{filtered.map((label) => (
<Autocomplete.Item key={label} value={label}>
{label}
</Autocomplete.Item>
))}
{trimmed && !hasExactMatch ? (
<Autocomplete.Create value={trimmed}>Use “{trimmed}”</Autocomplete.Create>
) : null}
</Autocomplete.List>
<Autocomplete.Empty>Type to create a label.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Auto-highlight
Set autoHighlight on Autocomplete.Root to keep the first match highlighted as the user types, so Enter commits it without arrowing down — a keyboard-first win for fast entry. (Pass autoHighlight="always" to keep the first row highlighted even before typing.)
"use client";
import { Autocomplete } from "@stridge/noctis";
const TAGS = ["bug", "chore", "documentation", "enhancement", "feature", "refactor", "regression", "test"];
export default function AutocompleteAutoHighlight() {
// `autoHighlight` keeps the first match highlighted as you type, so Enter commits it without arrowing
// down — a keyboard-first win for fast entry.
return (
<div className="w-72">
<Autocomplete.Root items={TAGS} autoHighlight>
<Autocomplete.Input aria-label="Tag" placeholder="Search tags…" />
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No tag found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Inline completion
Set mode="both" to filter the list and complete the first match inline — the field temporarily shows the highlighted suggestion as a selected range you can accept or type past. Pair it with autoHighlight so the top match is active and Enter commits it without arrowing. (mode="inline" completes without filtering; mode="list", the default, only filters; mode="none" does neither.)
"use client";
import { Autocomplete } from "@stridge/noctis";
const TIMEZONES = [
"Africa/Cairo",
"America/New_York",
"America/Sao_Paulo",
"Asia/Tokyo",
"Australia/Sydney",
"Europe/Berlin",
"Europe/London",
"Pacific/Auckland",
];
export default function AutocompleteInlineCompletion() {
// `mode="both"` filters the list AND completes the first match inline as a selected range in the
// field; `autoHighlight` keeps that top match active so Enter commits it without arrowing.
return (
<div className="w-72">
<Autocomplete.Root items={TIMEZONES} mode="both" autoHighlight>
<Autocomplete.Input aria-label="Timezone" placeholder="Type a timezone…" />
<Autocomplete.Content>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No timezone found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Recent and suggestions
Section the popup with Autocomplete.Group + Autocomplete.GroupLabel, mapping each group's filtered items through Autocomplete.Collection, and divide sections with Autocomplete.Separator. Set openOnFocus on the root so the sections surface the moment the field is focused — the command-palette idiom — with recents in reverse-chronological order.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { Fragment } from "react";
interface Section {
value: string;
items: string[];
}
// "Recent" is reverse-chronological (most recent first); "Suggestions" is the broader catalog.
const SECTIONS: Section[] = [
{ value: "Recent", items: ["dashboard.tsx", "auth.ts", "README.md"] },
{ value: "Suggestions", items: ["package.json", "tsconfig.json", "vite.config.ts", "Dockerfile"] },
];
export default function AutocompleteRecentAndSuggestions() {
// `openOnFocus` surfaces the sections the moment the field is focused — before any typing — the
// command-palette idiom; typing then filters within each section.
return (
<div className="w-72">
<Autocomplete.Root items={SECTIONS} openOnFocus>
<Autocomplete.Input aria-label="File" placeholder="Jump to a file…" />
<Autocomplete.Content>
<Autocomplete.List>
{(section: Section, index: number) => (
<Fragment key={section.value}>
{index > 0 ? <Autocomplete.Separator /> : null}
<Autocomplete.Group items={section.items}>
<Autocomplete.GroupLabel>{section.value}</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.Collection>
</Autocomplete.Group>
</Fragment>
)}
</Autocomplete.List>
<Autocomplete.Empty>No files found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Limiting results
For a long catalog, cap the visible rows with limit on Autocomplete.Root and render an Autocomplete.Status that says how many of the total matched, so a keyboard user knows the list is truncated.
"use client";
import { Autocomplete } from "@stridge/noctis";
import { useState } from "react";
// A long catalog where showing every match would overwhelm — cap the visible rows and say so.
const CITIES = [
"Amsterdam",
"Athens",
"Bangkok",
"Barcelona",
"Berlin",
"Cairo",
"Chicago",
"Copenhagen",
"Dubai",
"Dublin",
"Helsinki",
"Istanbul",
"Lisbon",
"London",
"Madrid",
"Melbourne",
"Mumbai",
"Nairobi",
"Oslo",
"Paris",
"Prague",
"Rome",
"Seoul",
"Singapore",
"Stockholm",
"Sydney",
"Tokyo",
"Toronto",
"Vienna",
"Warsaw",
];
const LIMIT = 6;
export default function AutocompleteLimit() {
const [query, setQuery] = useState("");
const matches = query.trim()
? CITIES.filter((city) => city.toLowerCase().includes(query.trim().toLowerCase())).length
: CITIES.length;
// `limit` caps how many rows the popup renders; the Status announces how many of the total matched,
// so a keyboard user knows the list is truncated.
return (
<div className="w-72">
<Autocomplete.Root items={CITIES} limit={LIMIT} onValueChange={setQuery}>
<Autocomplete.Input aria-label="City" placeholder="Search cities…" />
<Autocomplete.Content>
<Autocomplete.Status>
{matches > LIMIT
? `Showing ${LIMIT} of ${matches} matches`
: `${matches} ${matches === 1 ? "match" : "matches"}`}
</Autocomplete.Status>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
<Autocomplete.Empty>No cities found.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
);
}
Validation
As a member of the field family, Autocomplete takes the shared invalid contract: compose it inside a Field.Root for the full label / description / error wiring, and the field reports invalid through Field automatically. Set invalid on Autocomplete.Input as an additive override. Invalid shifts the border to the danger role — the same recipe as Input and Combobox, no bespoke red and no extra ring.
Keyboard
Filtering and navigation are Base UI's, fully keyboard-operable and RTL-aware. Focus stays on the input; aria-autocomplete resolves to list for mode="list" and both for mode="both", aria-activedescendant tracks the highlighted row, and aria-expanded mirrors the popup's open state.
| Key | Action |
|---|---|
| Type | Filter the suggestions and open the popup; in both/inline mode, inline-complete the first match into a selected range. |
| ↓ / ↑ | Open if closed; move the highlight through the suggestions. |
| Alt + ↓ | Open the popup without moving the highlight. |
| Home / End | Move the text caret to the start / end of the field. |
| Enter | Commit the highlighted (or auto-highlighted) suggestion; accept the typed value on a Create row. |
| Esc | Close the popup, keeping the typed text. |
Accessibility
- Async announcements.
Autocomplete.Statusis a polite live region that must stay mounted — Base UI announces its content changes (searching, results count) without moving focus. The trailingLoadingspinner also carries a localized hidden "Loading" label and setsaria-busyon the field. - Affordance labels.
Clearis an icon-only button; it carries a localized defaultaria-labelyou can override. The leadingIconis decorative (aria-hidden). - Inverted contrast. The match
<mark>is weight + foreground on a transparent ground, so it never relies on colour alone. - Known gap. Base UI's internal dismiss control has a hardcoded English
aria-labelthat no consumer prop reaches, so it is not yet translatable from this layer — pending an upstream Base UI change. Everything else localizes through the active locale. - Reduced motion. The popup scale and the loading spinner both respect
prefers-reduced-motion; the spinner falls back to a static glyph, and theStatustext still conveys progress.
Anatomy
Compose an autocomplete from its parts. Autocomplete.Root owns the state, the items, and the control size, and shares the size and the loading flag with the field and rows through context.
Autocomplete.Root— owns the suggestionitems, the inputvalue(value/onValueChange, ordefaultValue), the open state, themode(list/both/inline/none),autoHighlight,openOnFocus,loading,limit, and the controlsize. Renders no element.Autocomplete.Input— the text field; takes an optionalsizeoverride and aninvalidflag.Autocomplete.InputGroup— the field shell that lays the input beside its leadingIconand trailingClear/Loadingaffordances.Autocomplete.Icon— a leading decorative field glyph (search magnifier by default).Autocomplete.Clear— a button that clears the value, shown only while the field holds one.Autocomplete.Loading— the trailing async spinner, shown while the root isloading.Autocomplete.Content— the portalled, elevated popup (aSurface); takesside/align/sideOffset/collisionPaddingpositioning props.Autocomplete.List— the scrollable listbox; pass a render function to map each filtered item to anItem.Autocomplete.Item— one suggestion row, with an optional leadingicon.Autocomplete.Create— the conditional free-text "Use «query»" row; give it the typed query as itsvalue.Autocomplete.Highlight— bolds the matched substring of a suggestion (text+query).Autocomplete.Empty— the polite no-matches message shown inside the popup.Autocomplete.Status— a polite status line for an asynchronously loaded list.Autocomplete.Group+Autocomplete.GroupLabel+Autocomplete.Collection+Autocomplete.Separator— a labelled section, its filtered rows, and the divider between sections.
Every rendered part carries a data-slot under the noctis-autocomplete-* prefix (-input, -content, -list, -item, -empty, -status, -group-label, and the styling-only -input-group, -icon, -clear, -loading, -mark, -positioner, -group, -separator) for host-side styling. The field and popup mirror the root size as data-size; a suggestion row marks the active item with data-highlighted, dims on data-disabled, and the field carries data-invalid while invalid.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every autocomplete in that region retunes — e.g. .dense { --noctis-autocomplete-item-height: 1.75rem; } tightens every suggestion row beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; the Root carries the items, value, and open-state props that the parts read through context. Expand a row for the full type and description.
Autocomplete.Root
Autocomplete.Input
Autocomplete.InputGroup
Autocomplete.Icon
Autocomplete.Clear
Autocomplete.Loading
Autocomplete.Content
Autocomplete.List
Autocomplete.Item
Autocomplete.Create
Autocomplete.Highlight
Autocomplete.Empty
Autocomplete.Status
Autocomplete.Group
Autocomplete.GroupLabel
Autocomplete.Collection
No props of its own — forwards to the underlying Base UI part.