An emoji picker for reactions and for putting emoji into text. It has a search field, a bar of categories, an optional row of quick reactions, a skin tone choice and a grid of the platform's own emoji, in a Popover that is a bottom sheet on phones. The app passes the emoji by category, with names and keywords in its own language, and the person's frequently used ones. With catalogue it offers an image catalogue's emoji instead (a standard set and an organization's custom emoji), drawn only as the catalogue's images, an animated one playing while it is in focus or under the mouse, and gives onSelect the emoji's id and the :shortcode: token to put into text. A catalogue's images are all fetched as the picker opens, the quick row and the grid's first eight rows first; while the browser asks to save data, the rest of the grid's wait until they near the screen.
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/emoji-picker#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/emoji-pickerIts 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/emoji-picker#v1.3.10 ourostack/ouro-ui/emoji-picker-tests#v1.3.10import { EmojiPicker } from "@/components/ouro/emoji-picker/emoji-picker";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<EmojiPicker
label="Add a reaction"
trigger={<Button variant="plain" aria-label="Add a reaction"><SmileIcon /></Button>}
categories={categories}
frequent={frequent}
quick={quickReactions}
skinTone={tone}
onSkinToneChange={setTone}
onSelect={(selection) => react(message.id, selection.emoji)}
/>
<EmojiPicker
label="Add a reaction"
trigger={<Button variant="plain" aria-label="Add a reaction"><SmileIcon /></Button>}
catalogue={catalogue}
frequent={recentIds}
quick={["like", "heart", "laugh"]}
placeholder="/emoji/waiting.svg"
onSelect={(selection) => react(message.id, selection.id)}
/>Usage
The search matches the start of any word in an emoji's name, keywords or shortcodes, ignoring case and accents, and announces how many it found. The grid is one Tab stop: the arrow keys move through it, Home and End go to the ends of a row (with Ctrl, of the grid), Page Down and Page Up go to the next and previous category, and Enter chooses. Every emoji is named for screen readers, and the one in focus shows large with its name under the grid. Choosing one calls onSelect and closes the picker; Escape closes it too.
A closed picker makes nothing from its emoji, so one in each conversation's reply box costs next to nothing to mount; the first to open makes what it shows from a catalogue, once for every picker given that catalogue.
Use instead when
- Use
ReactionBarinstead to show a message's reactions and its few quick ones, with an EmojiPicker beside it for the rest. - Use
Commandinstead for a searchable list of actions.
Props
The props the modes share. Type: EmojiPickerProps.
align- Type:
"start" | "center" | "end" - Default:
"center" - How the popover lines up with the trigger along that side.
categoriesLabel- Type:
string - Default:
"Categories" - What screen readers hear for the category bar.
className- Type:
string - Layout only, applied to the panel inside the popover: width (
w-96). Appearance classes warn in development. closeLabel- Type:
string - Default:
"Close" - The label of the popover's close button, which only screen-reader and keyboard users see.
frequentLabel- Type:
string - Default:
"Frequently used" - The heading of the frequently used emoji.
label(required)- Type:
string - What the picker is for, which names it for screen readers:
"Add a reaction","Insert emoji". Required. noResultsLabel- Type:
string - Default:
"No emoji found" - What shows, and is announced, when nothing matches the search.
onSkinToneChange- Type:
((skinTone: EmojiSkinTone) => void) - Called with the new skin tone when the person picks one, so the app can keep it.
presentation- Type:
"popover" | "auto" | "sheet" - Default:
"auto" auto, the default, is a popover from 640px up and a bottom sheet below.popoverstays beside the trigger at every width;sheetis always a bottom sheet.quickLabel- Type:
string - Default:
"Quick reactions" - What screen readers hear for the
quickrow. resultsLabel- Type:
string - Default:
"{count} emoji" - The heading of the search results, also announced as they change;
{count}is the number found. searchLabel- Type:
string - Default:
"Search emoji" - The search field's label and placeholder.
side- Type:
"top" | "bottom" | "left" | "right" - Default:
"bottom" - The side of the trigger the popover sits on; it flips when there is no room.
skinTone- Type:
"default" | "light" | "medium-light" | "medium" | "medium-dark" | "dark" - The skin tone the emoji show in, when you keep it (in the person's settings, say). Pair it with
onSkinToneChange. Without it, the picker remembers the tone while it is mounted. skinToneLabel- Type:
string - Default:
"Skin tone" - The name of the skin tone button and its choices.
skinToneNames- Type:
Readonly<Record<EmojiSkinTone, string>> - Default: { default: "Default", light: "Light", "medium-light": "Medium-light", medium: "Medium", "medium-dark": "Medium-dark", dark: "Dark" }
- The name of each skin tone, for other languages.
Modes
EmojiPickerProps is a union: pass the props of exactly one mode. Mixing them is a type error.
Catalogue controlled: EmojiPickerCatalogueControlledProps
A controlled EmojiPicker of an image catalogue's emoji: you own open, update it in onOpenChange, and say what it points at with anchor.
anchor(required)- Type:
RefObject<HTMLElement | null> - The element the picker points at, usually the button that toggles it:
anchor={buttonRef}. catalogue(required)- Type:
EmojiCatalogue - The emoji the picker offers, as images, by category, in order: a standard set, and an organization's custom emoji in a category of their own. The search matches their names, shortcodes and keywords.
frequent- Type:
readonly string[] - The ids of the person's frequently used or recent emoji, which the app keeps (a tone's id shows its emoji). Shown first, under
frequentLabel. An id the catalogue no longer has is left out. onOpenChange(required)- Type:
(open: boolean) => void - Called with
falsewhen the person dismisses the picker (Escape, an outside press, the close button) or chooses an emoji. onSelect(required)- Type:
(selection: EmojiPickerCatalogueSelection, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void - Called with the chosen emoji when the person presses one, or presses Enter in the search for its first result; the picker then closes. The event comes second, so the app can check
event.isTrustedbefore it acts. open(required)- Type:
boolean - Whether the picker is open.
placeholder- Type:
string | ReactElement - Shows in each emoji's place until its picture has loaded and decoded, as
Emoji'splaceholder: an image's address, or an element. The picker passes it to every emoji it draws: the grid, the quick row, the preview, the category buttons and the skin tones. Pass a value that stays the same between renders (an address, or an element made once), so the grid is not redrawn while the app renders. quick- Type:
readonly string[] - The ids of a row of emoji above the categories for a single press, such as the quick reactions on a message.
Not in this mode (typed never):
categories: Not withcatalogue. The emoji come from the catalogue: removecategories.renderEmoji: Not withcatalogue. The picker draws the catalogue's images.trigger: Not withopen. Removeopen,onOpenChangeandanchorto let a trigger open the picker.
Catalogue with trigger: EmojiPickerCatalogueWithTriggerProps
An uncontrolled EmojiPicker of an image catalogue's emoji: trigger opens and closes it.
catalogue(required)- Type:
EmojiCatalogue - The emoji the picker offers, as images, by category, in order: a standard set, and an organization's custom emoji in a category of their own. The search matches their names, shortcodes and keywords.
frequent- Type:
readonly string[] - The ids of the person's frequently used or recent emoji, which the app keeps (a tone's id shows its emoji). Shown first, under
frequentLabel. An id the catalogue no longer has is left out. onSelect(required)- Type:
(selection: EmojiPickerCatalogueSelection, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void - Called with the chosen emoji when the person presses one, or presses Enter in the search for its first result; the picker then closes. The event comes second, so the app can check
event.isTrustedbefore it acts. placeholder- Type:
string | ReactElement - Shows in each emoji's place until its picture has loaded and decoded, as
Emoji'splaceholder: an image's address, or an element. The picker passes it to every emoji it draws: the grid, the quick row, the preview, the category buttons and the skin tones. Pass a value that stays the same between renders (an address, or an element made once), so the grid is not redrawn while the app renders. quick- Type:
readonly string[] - The ids of a row of emoji above the categories for a single press, such as the quick reactions on a message.
trigger(required)- Type:
ReactElement - The element that opens and closes the picker, usually an icon Button:
trigger={<Button aria-label="Add a reaction">…</Button>}.
Not in this mode (typed never):
anchor: Not withtrigger. The picker sits beside its trigger.categories: Not withcatalogue. The emoji come from the catalogue: removecategories.onOpenChange: Not withtrigger. Removetriggerto control the picker withopen,onOpenChangeandanchor.open: Not withtrigger. Removetriggerto control the picker withopen,onOpenChangeandanchor.renderEmoji: Not withcatalogue. The picker draws the catalogue's images.
Controlled: EmojiPickerControlledProps
A controlled EmojiPicker of the platform's emoji: you own open, update it in onOpenChange, and say what it points at with anchor.
anchor(required)- Type:
RefObject<HTMLElement | null> - The element the picker points at, usually the button that toggles it:
anchor={buttonRef}. categories(required)- Type:
readonly EmojiPickerCategory[] - Every emoji the picker offers, by category, in order. The app supplies them, so it picks the set, the names and the language.
frequent- Type:
readonly EmojiPickerEmoji[] - The person's frequently used emoji, which the app keeps. Shown first, under
frequentLabel, with its own button in the category bar. onOpenChange(required)- Type:
(open: boolean) => void - Called with
falsewhen the person dismisses the picker (Escape, an outside press, the close button) or chooses an emoji. onSelect(required)- Type:
(selection: EmojiPickerSelection, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void - Called with the chosen emoji when the person presses one, or presses Enter in the search for its first result; the picker then closes. The event comes second, so the app can check
event.isTrustedbefore it acts. open(required)- Type:
boolean - Whether the picker is open.
quick- Type:
readonly EmojiPickerEmoji[] - A row of emoji above the categories for a single press, such as the quick reactions on a message.
renderEmoji- Type:
EmojiRender - Draws an emoji in the grid, the quick row, the preview, the category buttons (each category's first emoji) and the skin tone choice (the first emoji with tones), for an app with its own emoji art: return an image 1em square, so it takes the size of the text, with an empty
alt(the button already has the emoji's name). Pass a function that stays the same between renders, so moving focus redraws only the emoji it leaves and reaches. Without it, the picker shows the platform's own glyphs, in the skin tone in use.
Not in this mode (typed never):
catalogue: Not withcategories. Removecategories,frequentandquickto offer an image catalogue's emoji instead.placeholder: Not withcategories. A placeholder stands in for a catalogue's pictures;renderEmojidraws your own art.trigger: Not withopen. Removeopen,onOpenChangeandanchorto let a trigger open the picker.
With trigger: EmojiPickerWithTriggerProps
An uncontrolled EmojiPicker of the platform's emoji: trigger opens and closes it.
categories(required)- Type:
readonly EmojiPickerCategory[] - Every emoji the picker offers, by category, in order. The app supplies them, so it picks the set, the names and the language.
frequent- Type:
readonly EmojiPickerEmoji[] - The person's frequently used emoji, which the app keeps. Shown first, under
frequentLabel, with its own button in the category bar. onSelect(required)- Type:
(selection: EmojiPickerSelection, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void - Called with the chosen emoji when the person presses one, or presses Enter in the search for its first result; the picker then closes. The event comes second, so the app can check
event.isTrustedbefore it acts. quick- Type:
readonly EmojiPickerEmoji[] - A row of emoji above the categories for a single press, such as the quick reactions on a message.
renderEmoji- Type:
EmojiRender - Draws an emoji in the grid, the quick row, the preview, the category buttons (each category's first emoji) and the skin tone choice (the first emoji with tones), for an app with its own emoji art: return an image 1em square, so it takes the size of the text, with an empty
alt(the button already has the emoji's name). Pass a function that stays the same between renders, so moving focus redraws only the emoji it leaves and reaches. Without it, the picker shows the platform's own glyphs, in the skin tone in use. trigger(required)- Type:
ReactElement - The element that opens and closes the picker, usually an icon Button:
trigger={<Button aria-label="Add a reaction">…</Button>}.
Not in this mode (typed never):
anchor: Not withtrigger. The picker sits beside its trigger.catalogue: Not withcategories. Removecategories,frequentandquickto offer an image catalogue's emoji instead.onOpenChange: Not withtrigger. Removetriggerto control the picker withopen,onOpenChangeandanchor.open: Not withtrigger. Removetriggerto control the picker withopen,onOpenChangeandanchor.placeholder: Not withcategories. A placeholder stands in for a catalogue's pictures;renderEmojidraws your own art.
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.
data: Not a prop. The emoji come by category incategories:<EmojiPicker categories={[{ label, emoji }]}>.isOpen: Not a prop. Ouro UI usesopenwithonOpenChange:<EmojiPicker open={open} onOpenChange={setOpen} anchor={buttonRef}>.onClose: Not a prop.onOpenChange(false)fires on close:<EmojiPicker open={open} onOpenChange={setOpen} anchor={buttonRef}>.onEmojiClick: Not a prop. A chosen emoji callsonSelect:<EmojiPicker onSelect={(selection) => insert(selection.emoji)}>.onEmojiSelect: Not a prop. A chosen emoji callsonSelect:<EmojiPicker onSelect={(selection) => insert(selection.emoji)}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: EmojiPicker merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: use the align prop.
Types
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;
}EmojiPickerCatalogueSelection
What onSelect receives from a picker with a catalogue: the emoji chosen, in the skin tone it was chosen in.
interface EmojiPickerCatalogueSelection {
/** The emoji's id, or its tone's id when it was chosen in a skin tone: `"thumbs-up-tone3"`. Keep it to react with, or among the person's frequently used emoji. */
id: string;
/** Its name: `"Thumbs up"`. */
name: string;
/**
* The text to put into a message for it, `:shortcode:` (its first shortcode, else its id, and
* in a tone the tone's id), which `EmojiText` draws as the image: `":party:"`.
*/
token: string;
/** The skin tone it was chosen in; `"default"` for an emoji without tones. */
skinTone: EmojiSkinTone;
/** The catalogue's emoji, as the app passed it. */
item: EmojiItem;
}EmojiPickerCategory
A category of emoji: its name, the icon its button shows, and its emoji in order.
interface EmojiPickerCategory {
/** The category's name, shown as its heading and read for its button: `"Smileys"`. Unique within the picker. */
label: string;
/** What its button in the category bar shows: an icon element or image. Defaults to its first emoji, drawn by `renderEmoji` when given. */
icon?: ReactNode;
/** The category's emoji, in the order they show. */
emoji: readonly EmojiPickerEmoji[];
}EmojiPickerEmoji
One emoji the picker offers: the character, its name and the words that find it.
interface EmojiPickerEmoji {
/** The emoji as text, drawn with the platform's own emoji font: `"👍"`. */
emoji: string;
/** Its name, which screen readers hear and the search matches: `"thumbs up"`. */
name: string;
/** More words the search matches: `["like", "yes", "approve"]`. */
keywords?: readonly string[] | undefined;
/**
* It comes in skin tones. `true` puts Unicode's tone modifier after its first character
* (right for one person or hand, such as 👍 or 🧑💻); for an emoji where that is not
* right, such as two people holding hands, list its five toned forms, light to dark.
*/
skinTones?: boolean | readonly string[] | undefined;
}EmojiPickerSelection
What onSelect receives: the emoji chosen, in the skin tone it was chosen in.
interface EmojiPickerSelection {
/** The emoji as text, in the chosen skin tone when it has tones: `"👍🏽"`. */
emoji: string;
/** Its name: `"thumbs up"`. */
name: string;
/** The skin tone it was chosen in; `"default"` for an emoji without tones. */
skinTone: EmojiSkinTone;
/** The entry from `categories`, `frequent` or `quick` that was chosen, as the app passed it. */
item: EmojiPickerEmoji;
}EmojiRender
Draws an emoji in the given skin tone: renderEmoji.
type EmojiRender = (item: EmojiPickerEmoji, skinTone: EmojiSkinTone) => ReactNode;Claim sources
- layout-classes: test:registry/ouro/components/ouro/emoji-picker/emoji-picker.test.tsx::EmojiPicker opening and closing > lets app layout classes win on the panel
verify:testExists(registry/ouro/components/ouro/emoji-picker/emoji-picker.test.tsx::EmojiPicker opening and closing > lets app layout classes win on the panel)
