Documentation
Frimousse emoji picker, extended.
A lightweight, unstyled, and composable emoji picker for React, with practical extension seams for custom emoji, unified search, and frequent items. Broader emoji metadata policy lives in companion packages.
Installation
Install the package, then update imports in existing Frimousse usage.
Base API
Core picker parts, grouped search, and native-only helpers.
Extended API
Supplemental items, unified search, and consumer-owned frequent rows.
Companion packages
Companion packages that extend search and metadata without changing the picker contract.
API Reference
Components, props, hooks, helpers, and exported types.
Installation
If you are coming from upstream Frimousse, start by switching the package and imports, then opt into additive APIs when you need them.
Install
Add the package to your project.
pnpm add @slithy/frimousseMigrate
For baseline usage, migration from upstream Frimousse is usually just an import change. Existing native-only picker composition can stay as-is.
- import { EmojiPicker } from "frimousse";
+ import { EmojiPicker } from "@slithy/frimousse";Base API
For the complete native-only reference, see frimousse.liveblocks.io.
Import the EmojiPicker parts and create your own component by composing them.
The default picker uses the native emoji dataset, grouped search, loading and empty states, and keyboard navigation out of the box.
import { EmojiPicker } from "@slithy/frimousse";
export function MyEmojiPicker() {
return (
<EmojiPicker.Root>
<EmojiPicker.Search placeholder="Search emoji" />
<EmojiPicker.Viewport>
<EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>
);
}The parts are intentionally unstyled. Apply layout and visual styling in your own system. A small baseline stylesheet can target the native Frimousse attributes directly.
[frimousse-root] {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 14px;
display: flex;
flex-direction: column;
height: 368px;
overflow: hidden;
width: fit-content;
}
[frimousse-search] {
appearance: none;
background: #f5f5f5;
border: 0;
border-radius: 10px;
font-size: 14px;
height: 36px;
margin: 8px 8px 0;
padding: 0 12px;
}
[frimousse-viewport] {
flex: 1;
outline: none;
overflow: auto;
position: relative;
}
[frimousse-loading],
[frimousse-empty] {
color: #737373;
display: flex;
inset: 0;
align-items: center;
justify-content: center;
position: absolute;
}
[frimousse-list] {
padding-bottom: 6px;
}
[frimousse-category-header] {
background: #fff;
color: #737373;
font-size: 12px;
font-weight: 600;
padding: 12px 12px 6px;
}
[frimousse-row] {
padding: 0 6px;
scroll-margin: 6px;
}
[frimousse-emoji] {
align-items: center;
background: transparent;
border: 0;
border-radius: 8px;
display: flex;
font-size: 18px;
height: 32px;
justify-content: center;
width: 32px;
}
[frimousse-emoji][data-active],
[frimousse-emoji]:hover {
background: #f5f5f5;
}Using it in a popover
The picker only provides the emoji UI. If you want a menu, dialog, or popover around it, compose it with your existing overlay components.
Driving search from outside the picker
EmojiPicker.Search is optional.
If your app already has its own input, autocomplete field, or command bar, control search from EmojiPicker.Root instead and omit EmojiPicker.Search entirely.
import { useState } from "react";
import { EmojiPicker } from "@slithy/frimousse";
export function MyEmojiPicker() {
const [search, setSearch] = useState("");
return (
<>
<input
onChange={(event) => setSearch(event.target.value)}
placeholder="Search emoji"
type="text"
value={search}
/>
<EmojiPicker.Root
onSearchValueChange={setSearch}
searchValue={search}
>
<EmojiPicker.Viewport>
<EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>
</>
);
}Behavior notes
Root manages locale, skin tone, keyboard navigation, active state, and selection callbacks for the picker tree. Use Root, Search, Viewport, List, Loading, and Empty for the baseline composition.
Search filters the native dataset and keeps matching emoji grouped under their category headings. Use onEmojiSelect for native emoji selection. SkinToneSelector, SkinTone, ActiveEmoji, useSkinTone, and useActiveEmoji work with native emoji only.
If you need consumer-owned search UI, use searchValue and onSearchValueChange on Root. If you control EmojiPicker.Search directly with value, do not also try to drive it independently from a separate root-level search source.
List is virtualized, so custom row and category-header components should keep consistent dimensions for accurate scrolling and sticky headers.
Extended API
The Extended API keeps the base picker intact and adds consumer-owned sections, supplemental items, and widened selection surfaces on top.
Use it when you need one or more of these:
- image-backed or app-specific supplemental items
- mixed native and supplemental sections in one picker
- unified search across native and supplemental content
- a frequent-items row that your app owns and persists
The demo below combines supplemental image-backed items (scroll to the bottom), unified search, and a consumer-owned frequent row in one picker.
Supplemental sections
Pass supplemental to EmojiPicker.Root to add consumer-provided sections. Sections can contain native items, supplemental items, or both.
import {
EmojiPicker,
createSupplementalSection,
} from "@slithy/frimousse";
const supplementalSection = createSupplementalSection(
[
{ id: "party-parrot", imageUrl: "/emoji/party-parrot.png", label: "Party parrot" },
{ id: "ship-it", label: "Ship it", aliases: ["ship it"] },
],
{
id: "custom",
label: "Custom",
position: "append",
searchable: true,
},
);
<EmojiPicker.Root
supplemental={{
sections: [supplementalSection],
}}
>
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>;Use createCustomSection when you specifically want image-backed custom emoji with required imageUrl values. Use createSupplementalSection when your section can mix plain supplemental items and image-backed ones.
Supplemental item fields have distinct roles:
id: stable consumer-owned identitylabel: human-readable display nameshortcode: optional canonical display-oriented:shortcode:tokenaliases: alternate typed forms or nameskeywords: search-oriented descriptorstags: broader discovery hintsimageUrl: presentation datadata: opaque consumer payload carried through unchanged
If your app treats shortcode as the canonical identity for custom emoji, use that shortcode-like name as id and put alternate forms in aliases. If you also want an explicit canonical display token on the item itself, set shortcode.
Two common patterns:
- Shortcode-first custom emoji
{
id: "party_parrot",
label: "Party parrot",
shortcode: ":party_parrot:",
aliases: ["party-parrot", ":parrot_party:"],
}Use this when your app persists or exchanges shortcode-like names as the primary custom emoji identity.
- Plain image-backed supplemental items
{
id: "customer-success",
label: "Customer success",
imageUrl: "/emoji/customer-success.png",
keywords: ["support", "help"],
tags: ["team"],
}Use this when the item is picker content first, not part of a shortcode-oriented naming model.
Try not to treat these fields as interchangeable:
- do not use
labelas your stored identity - do not use
tagsas a substitute for canonical names - do not treat
aliasesas arbitrary metadata - do not put presentation-only concerns into
datawhen a first-class field already exists
Unified search
Supplemental search defaults to grouped results. Set mode: "unified" to merge native and supplemental matches into a single results surface.
In grouped mode, native matches stay under their native category headings and supplemental matches stay under the labels of the matching sections. Sections with no matches are omitted, and resultsLabel is ignored.
Use grouped mode when you want search to preserve where items come from:
- native matches stay grouped under native category headings
- supplemental matches stay grouped under their section labels
- the picker continues to communicate source and placement, not just relevance
Use unified mode when you want search to act like one merged emoji vocabulary:
- native and supplemental matches flatten into one results section
- ranking becomes the primary organizing signal
resultsLabelnames that merged results surface
Section-level searchable: false applies in both modes. A non-searchable section still renders normally when search is empty, but it does not contribute matches once search is active.
Unified search uses a built-in ranking policy. Name-like matches rank ahead of broader discovery matches:
- native labels and
search.native.termsentries are primary name-like signals - supplemental
label,aliases, and exact canonicalidmatches are primary name-like signals - tags and keywords are weaker secondary hints
The package does not currently expose a general native or cross-source weighting API. If you need different native ranking behavior, change the metadata you feed into search.native.terms rather than expecting a runtime weighting surface.
<EmojiPicker.Root
supplemental={{
sections: [supplementalSection],
search: {
mode: "unified",
resultsLabel: "Results",
},
}}
>
...
</EmojiPicker.Root>Item widening
Use onItemSelect, useActiveItem, or EmojiPicker.ActiveItem when your UI needs to react to both native and supplemental items.
Treat these as the preferred extensible surfaces for mixed-item pickers. The native-only surfaces remain available for compatibility and simpler native-only consumers.
If you start with the native-only surfaces and later add supplemental items, switch the whole trio together:
onEmojiSelect->onItemSelectuseActiveEmoji->useActiveItemEmojiPicker.ActiveEmoji->EmojiPicker.ActiveItem
import { EmojiPicker, type ItemSelection } from "@slithy/frimousse";
function MyEmojiPicker() {
const handleItemSelect = (selection: ItemSelection) => {
if (selection.kind === "native") {
console.log(selection.item.emoji);
return;
}
console.log(selection.item.imageUrl);
};
return (
<EmojiPicker.Root onItemSelect={handleItemSelect}>
...
</EmojiPicker.Root>
);
}onEmojiSelect remains native-only. Keep using it when you only care about native emoji picks.
If your UI needs one canonical display shortcode, use getEmojiPrimaryShortcode. If you need alternate forms too, use getEmojiShortcodes with the widened item surface. For native emoji, pass a companion-owned nativeShortcodes map when you want Unicode items to resolve to app-facing shortcode names.
Consumer-owned frequent items
The library does not persist recent or frequent selections for you. Instead, record usage in your own state and turn it into a prepend section when you want it.
The built-in helpers use a frecency model:
- each selection adds
1to the itemβs score - older scores decay over time before the next increment
- ranking is by decayed score first, then
lastUsedAt, then totaluses - the default score half-life is
30days
That gives you βfrequently usedβ behavior with a recency bias, without forcing storage or persistence policy into the picker.
If you want a strict βRecently usedβ section instead, pass mode: "recent" to
the frequency helpers.
import { useEffect, useMemo, useState } from "react";
import {
buildEmojiPickerFrequentSection,
EmojiPicker,
recordEmojiPickerUsage,
sanitizeEmojiPickerUsageEntries,
type EmojiPickerUsageEntry,
} from "@slithy/frimousse";
const STORAGE_KEY = "emoji-picker-usage";
function readEmojiPickerUsageEntries() {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return sanitizeEmojiPickerUsageEntries(raw ? JSON.parse(raw) : []);
} catch {
return [];
}
}
function MyEmojiPicker() {
const [usageEntries, setUsageEntries] = useState<EmojiPickerUsageEntry[]>(
readEmojiPickerUsageEntries,
);
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(usageEntries));
} catch {
// Storage is optional; keep the in-memory picker state working.
}
}, [usageEntries]);
const frequentSection = useMemo(
() =>
buildEmojiPickerFrequentSection(usageEntries, {
label: "Frequently used",
limit: 8,
searchable: false,
}),
[usageEntries],
);
return (
<EmojiPicker.Root
onItemSelect={(selection) => {
setUsageEntries((current) => recordEmojiPickerUsage(current, selection));
}}
supplemental={{
sections: frequentSection
? [frequentSection, supplementalSection]
: [supplementalSection],
search: { mode: "unified", resultsLabel: "Results" },
}}
>
...
</EmojiPicker.Root>
);
}That keeps storage fully consumer-owned while still giving you a small helper for rehydrating unknown persisted data safely.
Notes
SupplementalEmojiinEmojiPicker.Listonly needs to be customized if you want a different renderer for supplemental items.useActiveEmojiandEmojiPicker.ActiveEmojistay native-only.useActiveItemandEmojiPicker.ActiveItemreflect supplemental items too.- Companion packages like
@slithy/emoji-transformscan enrich native search, but the Extended API itself does not depend on them.
Companion packages
Companion packages add search data, metadata, or fallback-image tooling without changing the picker contract itself.
@slithy/emoji-transforms: search enrichment and shortcode metadata@slithy/emoji-compat: support detection and fallback metadata
@slithy/emoji-transforms
Install the package when you want richer native search terms or native shortcode metadata.
pnpm add @slithy/emoji-transformsUse it when you want one or more of these:
- richer native search vocabulary than the built-in dataset provides
- app-specific aliasing or shorthand for common emoji
- separate ownership of metadata policy from picker rendering
Enrich native search
Generate native search terms ahead of time, then pass them to Root through
search.native.terms.
The source records can use whatever metadata shape your app or companion package prefers. Frimousse only consumes the derived term map.
import {
buildNativeEmojiSearchTermMapFromEmojibase,
} from "@slithy/emoji-transforms";
import { EmojiPicker } from "@slithy/frimousse";
const emojiRecords = [
{
emoji: "π",
label: "Waving hand",
shortcodes: ["wave", "good_bye"],
tags: ["see you", "farewell"],
},
{
emoji: "π",
label: "Link",
shortcodes: ["link"],
tags: ["hyper link", "url"],
},
{
emoji: "β€οΈ",
label: "Red heart",
shortcodes: ["red_heart"],
tags: ["love"],
},
];
const nativeSearchTerms = buildNativeEmojiSearchTermMapFromEmojibase(
emojiRecords,
{ includeTags: true },
);
<EmojiPicker.Root
search={{
native: {
terms: nativeSearchTerms,
},
}}
>
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>;Frimousse also exports buildNativeSearchTermsMap when you want to build the
same shape from your own records directly.
For production apps, prefer computing these maps ahead of time and shipping the final plain objects to the client. That keeps bundle size and runtime work lower than importing full emoji datasets in the browser.
One simple pattern is to generate a checked-in artifact during a build or data-prep step:
// scripts/generate-emoji-maps.ts
import fs from "node:fs/promises";
import emojiData from "emojibase-data/en/data.json";
import iamcalShortcodes from "emojibase-data/en/shortcodes/iamcal.json";
import {
buildNativeEmojiSearchTermMapFromEmojibase,
buildShortcodeMapFromPreset,
} from "@slithy/emoji-transforms";
const nativeSearchTerms = buildNativeEmojiSearchTermMapFromEmojibase(emojiData, {
includeTags: true,
});
const nativeShortcodes = buildShortcodeMapFromPreset(
emojiData,
iamcalShortcodes,
);
await fs.writeFile(
"./src/generated/emoji-maps.ts",
[
"export const nativeSearchTerms = " +
JSON.stringify(nativeSearchTerms, null, 2),
"export const nativeShortcodes = " +
JSON.stringify(nativeShortcodes, null, 2),
].join("\n\n"),
);At runtime, import the generated plain objects instead of the raw datasets:
import { nativeSearchTerms, nativeShortcodes } from "./generated/emoji-maps";
import { EmojiPicker, getEmojiPrimaryShortcode } from "@slithy/frimousse";
<EmojiPicker.Root search={{ native: { terms: nativeSearchTerms } }}>
<EmojiPicker.Viewport>
<EmojiPicker.List />
</EmojiPicker.Viewport>
<EmojiPicker.ActiveItem>
{({ item }) =>
item?.kind === "native" ? (
<div>{getEmojiPrimaryShortcode(item, { nativeShortcodes })}</div>
) : null
}
</EmojiPicker.ActiveItem>
</EmojiPicker.Root>;Display native shortcodes
Build a native shortcode map separately when your UI needs one canonical display token for selected native emoji.
This is the simpler companion case: you already have native emoji in the
picker, and you only want a stable display token like :wave: or
:red_heart: for the active item, footer, or surrounding UI.
The same production advice applies here: precompute the shortcode map during your build or data-prep step when possible, then pass the final map into your picker UI at runtime.
import emojiData from "emojibase-data/en/data.json";
import iamcalShortcodes from "emojibase-data/en/shortcodes/iamcal.json";
import { buildShortcodeMapFromPreset } from "@slithy/emoji-transforms";
import { EmojiPicker, getEmojiPrimaryShortcode } from "@slithy/frimousse";
const nativeShortcodes = buildShortcodeMapFromPreset(
emojiData,
iamcalShortcodes,
);
<EmojiPicker.Root>
<EmojiPicker.Viewport>
<EmojiPicker.List />
</EmojiPicker.Viewport>
<EmojiPicker.ActiveItem>
{({ item }) =>
item?.kind === "native" ? (
<div>{getEmojiPrimaryShortcode(item, { nativeShortcodes })}</div>
) : null
}
</EmojiPicker.ActiveItem>
</EmojiPicker.Root>;Shortcode-first apps
Slack-style apps usually treat native and custom emoji differently:
- native emoji render by Unicode, but search and display often use shortcode-like names
- custom emoji use the shortcode-like name itself as the canonical identity
In Frimousse, that maps to:
- native search aliases ->
search.native.terms - native display shortcode ->
getEmojiPrimaryShortcode(..., { nativeShortcodes }) - custom emoji canonical name ->
id - alternate custom typed forms ->
aliases
For custom emoji, use the shortcode-like name as the supplemental id, then
put alternate forms in aliases.
This is the broader app-model case. The picker is not just displaying native shortcodes anymore; it is helping a mixed native/custom emoji vocabulary behave like one search system.
Search ranking stays opinionated by default:
- exact shortcode-like matches beat broader related matches
- native labels and enriched native terms are treated as name-like signals
- supplemental
label,aliases, and exact canonicalidmatches are treated as name-like signals too - tags and keywords remain weaker discovery signals
That means a query like heart should prefer :heart: over a pile of merely
related heart emoji, and a query like good_job should prefer the custom emoji
whose canonical id is good_job.
Only supplemental weighting is configurable today. Broader native or cross-source weighting controls are intentionally left out of the runtime API for now.
import { createCustomSection, EmojiPicker } from "@slithy/frimousse";
const customSection = createCustomSection(
[
{
id: "good_job",
label: "Good job",
imageUrl: "/emoji/good_job.gif",
aliases: [":good_job:", "good-job"],
},
{
id: "say_nothing",
label: "Say nothing",
imageUrl: "/emoji/say_nothing.gif",
aliases: [":say_nothing:", "say-nothing"],
},
],
{
id: "custom",
label: "Custom emoji",
},
);
<EmojiPicker.Root
supplemental={{
sections: [customSection],
search: { mode: "unified", resultsLabel: "Results" },
}}
>
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>;@slithy/emoji-transforms keeps the picker dumb. Frimousse only consumes the
search data you pass in. It does not need to know how those terms were
produced, where they came from, or which dataset policy your app uses.
@slithy/emoji-compat
Install the package when you want to detect native emoji support or serve fallback images for emoji above your supported browser floor.
pnpm add @slithy/emoji-compatUse it when you want one or more of these:
- browser support probing for native emoji rendering
- a compat map that marks which emoji need fallback images
- hexcode-based fallback URL resolution for self-hosted or CDN emoji assets
- build-time fallback asset manifest generation for Twemoji-style asset preparation
Build a compat map
@slithy/emoji-compat consumes emojibase-like records and returns plain data
keyed by native emoji strings.
import {
buildCompatMap,
getFallbackUrl,
} from "@slithy/emoji-compat";
const compatMap = buildCompatMap(records, {
supportedVersion: 15,
});
const fallbackUrl = getFallbackUrl(compatMap, "π«©");
// "/emoji/1fae9.svg"If you do not want to hard-code a support floor at runtime, buildCompatMap
can also detect the highest supported emoji version from the current browser.
Use the compat map in your UI
One common pattern is to build the compat map once, then let a small app-owned component choose between native rendering and an image fallback.
import {
buildCompatMap,
getFallbackUrl,
} from "@slithy/emoji-compat";
const compatMap = buildCompatMap(records, {
supportedVersion: 15,
});
function EmojiWithFallback({
emoji,
label,
}: {
emoji: string;
label: string;
}) {
const fallbackUrl = getFallbackUrl(compatMap, emoji, {
basePath: "/emoji",
});
if (!fallbackUrl) {
return <span aria-label={label}>{emoji}</span>;
}
return <img src={fallbackUrl} alt={label} width="20" height="20" />;
}That keeps the rendering policy in your app while @slithy/emoji-compat
handles the support decision and asset lookup.
Prepare fallback assets ahead of time
For production apps, prepare fallback assets and any generated compat map artifacts ahead of time, then import the final artifacts at runtime.
Use the assets subpath when you want a build-time fallback asset manifest for
the emoji assets you need above a chosen versionFloor.
// scripts/generate-emoji-fallbacks.ts
import {
buildFallbackAssetManifest,
downloadFallbackAssets,
} from "@slithy/emoji-compat/assets";
const fallbackAssetManifest = buildFallbackAssetManifest(records, {
versionFloor: 15,
});
await downloadFallbackAssets(fallbackAssetManifest, {
outDir: "public/emoji",
});Run that script as a normal Node build step:
tsx scripts/generate-emoji-fallbacks.tsUse that generated compat map in your component at runtime:
// src/generated/emoji-compat-map.ts
import type { EmojiCompatMap } from "@slithy/emoji-compat";
export const compatMap: EmojiCompatMap = {
"π«©": {
emoji: "π«©",
version: 16,
hexcode: "1fae9",
supported: false,
needsFallback: true,
},
};import { getFallbackUrl } from "@slithy/emoji-compat";
import { compatMap } from "./generated/emoji-compat-map";
function EmojiWithFallback({
emoji,
label,
}: {
emoji: string;
label: string;
}) {
const fallbackUrl = getFallbackUrl(compatMap, emoji, {
basePath: "/emoji",
});
return fallbackUrl ? (
<img src={fallbackUrl} alt={label} width="20" height="20" />
) : (
<span aria-label={label}>{emoji}</span>
);
}For repo readers, the package includes checked-in fallback asset manifest
samples for both the full output and the base-only --no-include-skins
variant.
Keep fallback policy separate from rendering
@slithy/emoji-compat does not render anything itself. It helps you answer:
- does this browser support this emoji natively?
- if not, what asset URL should I use?
- which fallback assets should I prepare ahead of time?
Your app still owns the browser support policy, generated artifact wiring, rendering component behavior, and asset hosting layout.
Notes
- Companion packages are optional. The base and extended picker APIs work without them.
@slithy/emoji-transformsenriches native search and shortcode metadata. It does not change the picker component surface.@slithy/emoji-compathandles support detection and fallback metadata. It does not render emoji or own asset hosting.- Native iamcal-style shortcodes belong in
search.native.terms, not in a picker-owned native emoji field. - For supplemental/custom emoji, canonical shortcode-like names belong in
id; alternate forms belong inaliases. - If you already have your own emoji metadata pipeline, you can build the same
search.native.termsshape and compat-map input shape yourself.