Suggestions at the text caret: typing a trigger character, such as @ for people or : for emoji, in a TextArea or TextField opens a list beside the caret, filtered by what follows the character. The arrow keys move through it, Enter or Tab inserts the suggestion in place of the trigger and its query, and Escape closes it. Focus stays in the text throughout: the text control points at the active suggestion with aria-activedescendant, and screen readers hear how many suggestions there are. Only the list scrolls to show the active suggestion, never a box around it or the page. The typed part of a suggestion's label shows in bold where it starts the label or one of its words. One control can have several triggers.
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/autocomplete#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/autocompleteIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/autocomplete#v1.3.10 ourostack/ouro-ui/autocomplete-tests#v1.3.10import { Autocomplete } from "@/components/ouro/autocomplete/autocomplete";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<Autocomplete
triggers={[
{ char: "@", label: "People", allowSpaces: true, items: (query) => people.filter((p) => p.label.toLowerCase().includes(query.toLowerCase())).slice(0, 6) },
{ char: ":", label: "Emoji", minQueryLength: 2, catalogue },
]}
>
<TextArea label="Message" hideLabel autoGrow rows={1} maxRows={8} value={text} onValueChange={setText} />
</Autocomplete>Usage
Insertion goes through the browser's own text editing, so Undo takes it back and the control's onChange and onValueChange run, controlled or not. TextArea's autoGrow grows with it. In a Composer, the Enter that picks a suggestion does not send. The text a pick inserted keeps the list closed, even after the caret leaves it and comes back, until the person edits it, so the next Enter sends. A trigger with an image emoji catalogue suggests its emoji as images and inserts their :shortcode: tokens, which EmojiText draws as images in the sent message.
Use instead when
- Use
Comboboxinstead to choose one value for a field from a list, andCommandfor a command palette.
Props
Type: AutocompleteProps.
children(required)- Type:
ReactElement - The text control: one
TextArea,TextField,Composer,<textarea>or<input>. Autocomplete gives it the list's ARIA, its keys while the list is open, and its ref. resultsLabel- Type:
((count: number, trigger: AutocompleteTrigger) => string) - Default: (count) => "1 suggestion" or "{count} suggestions"
- What screen readers hear when the list opens or its length changes.
triggers(required)- Type:
readonly AutocompleteTrigger[] - The characters that open suggestions, such as
@for people and:for emoji, with their lists.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
onSelect: Not a prop. A trigger takesonPick:{ char: "@", onPick: (item) => ... }.suggestions: Not a prop. A trigger'sitemsholds its suggestions.trigger: Not a prop. Ouro UI takes a list:<Autocomplete triggers={[{ char: "@", label: "People", items }]}>.
Types
AutocompleteItem
One suggestion in the list a trigger opens.
interface AutocompleteItem {
/** Identifies the suggestion, such as a person's id. Unique within one list. */
value: string;
/** The suggestion's name, shown in the list and read by screen readers. */
label: string;
/** Quieter text at the end of the row, such as a role or the shortcode `:tada:`. */
description?: string | undefined;
/** Shows an `Avatar` for the label, with its picture when `src` is set and its initials otherwise: `avatar: { src }`. */
avatar?: { src?: string | undefined; initials?: string | undefined } | undefined;
/** A small picture before the label instead of an avatar, such as an emoji: `glyph: "๐"`. */
glyph?: ReactNode;
/**
* An image catalogue's emoji, drawn before the label instead of an avatar or glyph; an
* animated one plays while the suggestion is active. A trigger with `catalogue` sets it.
*/
emoji?: EmojiItem | undefined;
/** The skin tone `emoji` is drawn in, for an emoji with `skinTones`. A trigger with `catalogue` and `skinTone` sets it. @default "default" */
skinTone?: EmojiSkinTone | (string & { 'Use "default", "light", "medium-light", "medium", "medium-dark" or "dark"': never }) | undefined;
/**
* The text that replaces the trigger and what follows it when the suggestion is picked.
* @default the trigger character, the label and a space: "@Avery Kim "
*/
insert?: string | undefined;
}AutocompleteTrigger
A character that opens suggestions, such as @ for people or : for emoji.
interface AutocompleteTrigger {
/** The character, such as "@". It opens the list at the start of the text or after a space or an opening bracket. */
char: string;
/** Names the list for screen readers: "People", "Emoji". */
label: string;
/**
* An image emoji catalogue to suggest from instead of `items`: the emoji whose shortcode,
* name or keyword starts with what follows the character, shortcodes first, at most eight,
* each drawn as its image with its `:shortcode:` token. Picking one inserts the token and a
* space, which `EmojiText` draws as the image once the message is sent.
*/
catalogue?: EmojiCatalogue | undefined;
/**
* The skin tone a `catalogue`'s emoji with tones are suggested and inserted in: the tone the
* person chose in EmojiPicker. Keep it in one place and give it to both, the picker's
* `skinTone` and `onSkinToneChange` and this, so a `:` suggestion inserts the same tone the
* picker does (`:thumbs-up-tone3:`). Emoji without tones are unchanged.
*
* @default "default"
*/
skinTone?: EmojiSkinTone | (string & { 'Use "default", "light", "medium-light", "medium", "medium-dark" or "dark"': never }) | undefined;
/**
* The suggestions: a list, which shows the items whose label, a word of it, or whose value
* starts with what follows the character, or a function of that text (`query`, "" just
* after the character) that filters and caps them itself. The list closes when none match.
*/
items?: readonly AutocompleteItem[] | ((query: string) => readonly AutocompleteItem[]) | undefined;
/** Called after a suggestion is inserted, with the suggestion and the query it replaced. */
onPick?: ((item: AutocompleteItem, query: string) => void) | undefined;
/** How many characters must follow the trigger before the list opens: a couple for emoji shortcodes, so a colon in a time stays quiet. @default 0 */
minQueryLength?: number | undefined;
/** Keeps the list open across spaces, for names with a first and last name. A line break still closes it. */
allowSpaces?: boolean | undefined;
}EmojiCatalogue
An image-based emoji set, by category: what EmojiPicker, ReactionBar, Autocomplete and EmojiText draw instead of the platform's emoji font when an app passes it. Put an organization's custom emoji in a category of their own, labelled by the app. The image addresses are used as they are, so an app that loads images its own way (a content security policy, a custom scheme, signed or blob: URLs) puts the addresses it can load in the catalogue when it builds it. Ouro indexes a catalogue's emoji once, to find them in text. Build the catalogue once and pass that object, or give each copy the same id: a catalogue of new emoji objects on every render is indexed again each time, and warns in development. Adding or removing emoji is seen in a new catalogue object, even in the same array; to edit an emoji's shortcodes or text, pass a new emoji object, or a new id.
interface EmojiCatalogue {
/**
* Names this set of emoji, for an app that makes a new catalogue object, emoji and all, each
* time it draws: catalogues with the same id share one index of their emoji, so change it when
* the emoji change (`"contoso-2026-11"`), an emoji edited in place included. Without one, a
* new catalogue object shares the index when it holds the same emoji objects in the same order,
* and a catalogue of new emoji objects builds its own.
*/
id?: string | undefined;
/** The categories, in the order they show. An emoji in more than one counts once, by its id. */
categories: readonly EmojiCatalogueCategory[];
}EmojiCatalogueCategory
A category of a catalogue: its name, the icon its button shows, and its emoji in order.
interface EmojiCatalogueCategory {
/** Identifies the category, unique in the catalogue: `"people"`, `"custom"`. */
id: string;
/** Its name, shown as its heading and read for its button: `"People"`, or the organization's name for its custom emoji. */
label: string;
/** What its button in a picker's category bar shows: an icon element. Defaults to its first emoji's image. */
icon?: ReactNode;
/** Its emoji, in the order they show. */
items: readonly EmojiItem[];
}EmojiFilmstrip
An animation drawn as a filmstrip: square frames stacked top to bottom in one image, so the frames are the image's height over its width.
interface EmojiFilmstrip {
/** The filmstrip image's address. */
src: string;
/** Frames a second. */
fps: number;
}EmojiItem
One emoji in an image catalogue: its id, its name and the image that draws it. An app maps its own emoji set to this, whether a standard set or an organization's custom emoji.
interface EmojiItem {
/** Identifies the emoji, unique in the catalogue: `"party"`. Reactions, recents and `:id:` tokens use it. */
id: string;
/** Its name, which is the image's `alt`, what screen readers hear and what the search matches first: `"Party popper"`. */
name: string;
/**
* The still image's address. It shows whenever the emoji is not playing, and always with reduced motion.
*/
src: string;
/** Sizes of the still image for sharper screens, as `<img srcset>`: `"party-2x.png 2x"`. */
srcSet?: string | undefined;
/**
* An animated image (GIF, animated WebP or PNG), which plays while the pointer is on the emoji
* or on the button around it, or that button has focus. With reduced motion it never plays.
*/
animatedSrc?: string | undefined;
/**
* The animation as a filmstrip, square frames stacked top to bottom in one image, for an
* emoji without `animatedSrc`: while the emoji plays, it steps through the frames at `fps`;
* otherwise, and always with reduced motion, `src` shows.
*/
filmstrip?: EmojiFilmstrip | undefined;
/** The still image's width in pixels, given to the `<img>` so the page keeps its shape while it loads. It shows at the text's size. */
width?: number | undefined;
/** The still image's height in pixels, as `width`. */
height?: number | undefined;
/** Short names typed between colons, without them: `["party", "tada"]`. The first makes the emoji's token, `:party:`. */
shortcodes?: readonly string[] | undefined;
/** More words the search matches: `["celebrate", "hooray"]`. */
keywords?: readonly string[] | undefined;
/**
* The emoji as plain text, such as its Unicode character (`"๐"`), for places an image cannot
* go (a copied message, a notification). `EmojiText` finds it in messages and draws the image;
* Ouro never draws it as text.
*/
text?: string | undefined;
/** It is the organization's or app's own emoji rather than one of the standard set. Ouro draws it the same; apps can label or treat it apart. */
custom?: boolean | undefined;
/**
* The emoji in Unicode's five skin tones, light to dark; leave it out for an emoji without tones.
*/
skinTones?: readonly EmojiItemTone[] | undefined;
}EmojiItemTone
An emoji's image in one skin tone: skinTones on an EmojiItem.
interface EmojiItemTone {
/** Identifies the emoji in this tone, unique in the catalogue: `"thumbs-up-tone3"`. Reactions and tokens use it. */
id: string;
/** The still image's address. */
src: string;
/** Sizes of the still image for sharper screens, as `<img srcset>`: `"wave-2x.png 2x"`. */
srcSet?: string | undefined;
/** An animated image (GIF, animated WebP or PNG), shown while the emoji plays; the still one shows otherwise. */
animatedSrc?: string | undefined;
/** The animation as a filmstrip of square frames, played while the emoji plays, for a tone without `animatedSrc`. */
filmstrip?: EmojiFilmstrip | undefined;
/**
* The emoji in this tone as text (`"๐๐ฝ"`), which `EmojiText` finds in messages; Ouro never draws it as text.
*/
text?: string | undefined;
}