DocsBrowse documentation
Recipes
Shortcode-first apps
Shortcode-first apps usually want one typed vocabulary across native and custom emoji.
Users search for wave, red_heart, or good_job, and expect the picker to do the
right thing regardless of where that item comes from.
That breaks down into two parts:
- native emoji need app-owned shortcode aliases in
search.native.terms, plus an optional shortcode map for display outside the list - custom emoji should use the shortcode-like name as the canonical
id, with any alternate typed forms inaliases
Use unified search so native aliases and custom emoji names are ranked together in one result set.
The example below is abbreviated. This pageβs demo uses a larger generated native term map and a fuller custom emoji section, but the wiring is the same.
// scripts/generate-shortcode-first-data.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 {
buildShortcodeMapFromPreset,
mergeNativeEmojiSearchTermMaps,
} from "@slithy/emoji-transforms";
const nativeShortcodes = buildShortcodeMapFromPreset(
emojiData,
iamcalShortcodes,
);
const nativeSearchTerms = mergeNativeEmojiSearchTermMaps(
nativeShortcodes,
{
"β€οΈ": ["heart", "love"],
},
);
await fs.writeFile(
"./src/generated/shortcode-first-data.ts",
[
"export const nativeShortcodes = " +
JSON.stringify(nativeShortcodes, null, 2),
"export const nativeSearchTerms = " +
JSON.stringify(nativeSearchTerms, null, 2),
].join("\n\n"),
);pnpm exec tsx scripts/generate-shortcode-first-data.tsimport {
createCustomSection,
EmojiPicker,
getEmojiPrimaryShortcode,
} from "@slithy/frimousse";
import {
nativeSearchTerms,
nativeShortcodes,
} from "./generated/shortcode-first-data";
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:"],
},
// ...more custom emoji data
],
{
id: "custom",
label: "Custom emoji",
},
);
<EmojiPicker.Root
search={{ native: { terms: nativeSearchTerms } }}
supplemental={{
sections: [customSection],
search: { mode: "unified", resultsLabel: "Results" },
}}
>
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>;
getEmojiPrimaryShortcode("β€οΈ", { nativeShortcodes });
// "red_heart"Generate the native maps ahead of time, then pass the final plain objects into the picker. The search and metadata setup on the Search & Metadata page is the same underlying pattern; this recipe just applies it to a Slack-style mixed native and custom emoji surface.
Reactions UI
Reaction UIs usually want a small stored shape and richer display metadata at render time. The simplest case is native-only reactions. A fuller app may also need custom emoji and native fallback images for platforms that do not support newer emoji.
Native-only reactions
If your reaction bar only deals with native emoji, store the emoji string itself and resolve the rest from app-owned maps at render time.
import {
getLabel,
getPrimaryShortcode,
} from "@slithy/emoji-transforms";
import {
nativeLabels,
nativeShortcodes,
} from "./generated/reaction-metadata";
type StoredNativeReaction = {
emoji: string;
count: number;
reacted: boolean;
};
function NativeReactionButton({
reaction,
}: {
reaction: StoredNativeReaction;
}) {
const label = getLabel(nativeLabels, reaction.emoji) ?? reaction.emoji;
const shortcode = getPrimaryShortcode(nativeShortcodes, reaction.emoji);
return (
<button
aria-label={label}
data-reacted={reaction.reacted || undefined}
title={shortcode ? `:${shortcode}:` : label}
type="button"
>
<span>{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
);
}Mixed reactions with compat fallback
Mixed reaction UIs usually need two rendering paths: custom emoji render through their app-owned
imageUrl, while native emoji render from the emoji character itself plus any app-owned label or
shortcode metadata.
If some viewers may be on platforms that do not support newer native emoji yet, @slithy/emoji-compat
supplies the fallback metadata for that native branch. Prebuild it alongside your native labels
and shortcodes, then let the renderer decide whether a native emoji should display as text or as a
fallback image. This usually matters when one user reacts from a newer OS or device and another
user opens the same UI on an older platform.
In practice, the reaction component needs to do two different things:
- if the reaction is custom, render the app-owned
imageUrl - if the reaction is native, check the compat map and either render the emoji as text or swap to a fallback image
// scripts/generate-reaction-assets.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 {
buildLabelMapFromEmojibase,
buildShortcodeMapFromPreset,
} from "@slithy/emoji-transforms";
import { buildCompatMap } from "@slithy/emoji-compat";
const nativeLabels = buildLabelMapFromEmojibase(emojiData);
const nativeShortcodes = buildShortcodeMapFromPreset(
emojiData,
iamcalShortcodes,
);
const compatMap = buildCompatMap(emojiData, {
supportedVersion: 15,
});
await fs.writeFile(
"./src/generated/reaction-metadata.ts",
[
"export const nativeLabels = " +
JSON.stringify(nativeLabels, null, 2),
"export const nativeShortcodes = " +
JSON.stringify(nativeShortcodes, null, 2),
"export const compatMap = " +
JSON.stringify(compatMap, null, 2),
].join("\n\n"),
);pnpm exec tsx scripts/generate-reaction-assets.tsPrepare the fallback image assets in a separate build step so the URLs returned by getFallbackUrl(...)
actually point at files your app serves:
// scripts/generate-reaction-fallback-assets.ts
import emojiData from "emojibase-data/en/data.json";
import {
buildFallbackAssetManifest,
downloadFallbackAssets,
} from "@slithy/emoji-compat/assets";
const fallbackAssetManifest = buildFallbackAssetManifest(emojiData, {
versionFloor: 15,
});
await downloadFallbackAssets(fallbackAssetManifest, {
outDir: "public/emoji/fallback",
});pnpm exec tsx scripts/generate-reaction-fallback-assets.tsimport {
getLabel,
getPrimaryShortcode,
} from "@slithy/emoji-transforms";
import { getFallbackUrl } from "@slithy/emoji-compat";
import {
compatMap,
nativeLabels,
nativeShortcodes,
} from "./generated/reaction-metadata";
type StoredReaction =
| {
kind: "native";
emoji: string;
count: number;
reacted: boolean;
}
| {
kind: "custom";
id: string;
label: string;
imageUrl: string;
count: number;
reacted: boolean;
};
function ReactionButton({ reaction }: { reaction: StoredReaction }) {
if (reaction.kind === "custom") {
return (
<button
aria-label={reaction.label}
data-reacted={reaction.reacted || undefined}
title={reaction.label}
type="button"
>
<img alt="" src={reaction.imageUrl} width="20" height="20" />
<span>{reaction.count}</span>
</button>
);
}
// Native emoji may need a compat-driven fallback image on older platforms.
const label = getLabel(nativeLabels, reaction.emoji) ?? reaction.emoji;
const shortcode = getPrimaryShortcode(nativeShortcodes, reaction.emoji);
const fallbackUrl = getFallbackUrl(compatMap, reaction.emoji, {
basePath: "/emoji/fallback",
extension: "svg",
});
return (
<button
aria-label={label}
data-reacted={reaction.reacted || undefined}
title={shortcode ? `:${shortcode}:` : label}
type="button"
>
{fallbackUrl ? (
<img alt="" src={fallbackUrl} width="20" height="20" />
) : (
<span>{reaction.emoji}</span>
)}
<span>{reaction.count}</span>
</button>
);
}If reactions come from the picker, use onItemSelect as the boundary where you convert the
selected item into your appβs stored reaction shape.
This follows the same build-time pattern as Search & Metadata: generate the native maps once, then import the final plain objects anywhere your reaction UI needs them. Use @slithy/emoji-compat when your reaction UI also needs a native fallback-image path for unsupported emoji.
Multilingual apps
Multilingual apps usually need to answer two separate questions:
- which locale should labels display in when more than one map is available?
- which languages should search recognize, and in what order?
Keep those as separate generated artifacts. Label maps and search-term maps may use similar merge patterns, but they serve different jobs at runtime.
Multilingual labels
For display labels, build one map per locale and merge them in precedence order. Earlier maps win.
// scripts/generate-multilingual-labels.ts
import fs from "node:fs/promises";
import englishData from "emojibase-data/en/data.json";
import japaneseData from "emojibase-data/ja/data.json";
import simplifiedChineseData from "emojibase-data/zh/data.json";
import traditionalChineseData from "emojibase-data/zh-hant/data.json";
import {
buildLabelMapFromEmojibase,
mergeNativeEmojiLabelMaps,
} from "@slithy/emoji-transforms";
const nativeLabels = mergeNativeEmojiLabelMaps(
buildLabelMapFromEmojibase(traditionalChineseData),
buildLabelMapFromEmojibase(simplifiedChineseData),
buildLabelMapFromEmojibase(japaneseData),
buildLabelMapFromEmojibase(englishData),
);
await fs.writeFile(
"./src/generated/multilingual-labels.ts",
"export const nativeLabels = " +
JSON.stringify(nativeLabels, null, 2),
);pnpm exec tsx scripts/generate-multilingual-labels.tsimport { getLabel } from "@slithy/emoji-transforms";
import { nativeLabels } from "./generated/multilingual-labels";
const label = getLabel(nativeLabels, "β€οΈ");This is a good fit when your UI should prefer one language, but still fall back through other product-supported locales before landing on English.
Multilingual search
Search usually wants broader recall than labels do. Build the active locale terms first, then merge in the fallback languages your product wants to recognize.
// scripts/generate-multilingual-search.ts
import fs from "node:fs/promises";
import englishData from "emojibase-data/en/data.json";
import japaneseData from "emojibase-data/ja/data.json";
import simplifiedChineseData from "emojibase-data/zh/data.json";
import traditionalChineseData from "emojibase-data/zh-hant/data.json";
import {
buildFallbackTermsFromEmojibase,
buildNativeEmojiSearchTermMapFromEmojibase,
mergeNativeEmojiSearchTermMaps,
} from "@slithy/emoji-transforms";
const nativeSearchTerms = mergeNativeEmojiSearchTermMaps(
buildNativeEmojiSearchTermMapFromEmojibase(traditionalChineseData, {
includeLabel: true,
}),
buildFallbackTermsFromEmojibase(simplifiedChineseData),
buildFallbackTermsFromEmojibase(japaneseData),
buildFallbackTermsFromEmojibase(englishData),
);
await fs.writeFile(
"./src/generated/multilingual-search.ts",
"export const nativeSearchTerms = " +
JSON.stringify(nativeSearchTerms, null, 2),
);pnpm exec tsx scripts/generate-multilingual-search.tsimport { EmojiPicker } from "@slithy/frimousse";
import { nativeSearchTerms } from "./generated/multilingual-search";
<EmojiPicker.Root
locale="zh-Hant"
search={{
native: {
terms: nativeSearchTerms,
},
}}
>
...
</EmojiPicker.Root>;This follows the same build-time pattern as Search & Metadata: generate the final locale-aware maps once, then import plain objects into the picker or surrounding UI.
User-selected primary language
If your app lets each user choose a preferred language, do not force every user through one global multilingual merge. A better fit is to prebuild one artifact per locale, then compose the final label or search maps at runtime from the signed-in userβs primary language plus your appβs fallback policy.
// scripts/generate-locale-artifacts.ts
import fs from "node:fs/promises";
import englishData from "emojibase-data/en/data.json";
import japaneseData from "emojibase-data/ja/data.json";
import simplifiedChineseData from "emojibase-data/zh/data.json";
import traditionalChineseData from "emojibase-data/zh-hant/data.json";
import {
buildFallbackTermsFromEmojibase,
buildLabelMapFromEmojibase,
buildNativeEmojiSearchTermMapFromEmojibase,
} from "@slithy/emoji-transforms";
const labelMapsByLocale = {
en: buildLabelMapFromEmojibase(englishData),
ja: buildLabelMapFromEmojibase(japaneseData),
zh: buildLabelMapFromEmojibase(simplifiedChineseData),
"zh-Hant": buildLabelMapFromEmojibase(traditionalChineseData),
};
const searchMapsByLocale = {
en: buildFallbackTermsFromEmojibase(englishData),
ja: buildFallbackTermsFromEmojibase(japaneseData),
zh: buildFallbackTermsFromEmojibase(simplifiedChineseData),
"zh-Hant": buildNativeEmojiSearchTermMapFromEmojibase(
traditionalChineseData,
{
includeLabel: true,
},
),
};
await fs.writeFile(
"./src/generated/locale-artifacts.ts",
[
"export const labelMapsByLocale = " +
JSON.stringify(labelMapsByLocale, null, 2),
"export const searchMapsByLocale = " +
JSON.stringify(searchMapsByLocale, null, 2),
].join("\n\n"),
);import {
mergeNativeEmojiLabelMaps,
mergeNativeEmojiSearchTermMaps,
} from "@slithy/emoji-transforms";
import {
labelMapsByLocale,
searchMapsByLocale,
} from "./generated/locale-artifacts";
function getUserLabelMap(userLocale: string) {
if (userLocale === "zh-Hant") {
return mergeNativeEmojiLabelMaps(
labelMapsByLocale["zh-Hant"],
labelMapsByLocale.zh,
labelMapsByLocale.en,
);
}
if (userLocale === "ja") {
return mergeNativeEmojiLabelMaps(
labelMapsByLocale.ja,
labelMapsByLocale.en,
);
}
return labelMapsByLocale.en;
}
function getUserSearchMap(userLocale: string) {
if (userLocale === "zh-Hant") {
return mergeNativeEmojiSearchTermMaps(
searchMapsByLocale["zh-Hant"],
searchMapsByLocale.zh,
searchMapsByLocale.en,
);
}
if (userLocale === "ja") {
return mergeNativeEmojiSearchTermMaps(
searchMapsByLocale.ja,
searchMapsByLocale.en,
);
}
return searchMapsByLocale.en;
}This model keeps locale policy in your app instead of baking one fallback order into a single artifact for every user. Build once per locale, then compose per user.
Self-hosted or Offline-capable Emojibase
The picker fetches native emoji data at runtime by default. If you do not want to depend on the
public Emojibase CDN, stage the required locale files into your own app and point emojibaseUrl
at that hosted path.
pnpm exec frimousse-stage-emojibase-data --out ./public/emojibase-dataThat stages the required data.json and messages.json files for en. For multiple locales:
pnpm exec frimousse-stage-emojibase-data \
--out ./public/emojibase-data \
--locales en,fr,ja,zh-hantIf you need to refresh files that already exist:
pnpm exec frimousse-stage-emojibase-data \
--out ./public/emojibase-data \
--locales en,fr,ja,zh-hant \
--overwrite<EmojiPicker.Root locale="en" emojibaseUrl="/emojibase-data">
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>With that setup, the picker loads /emojibase-data/{locale}/data.json and
/emojibase-data/{locale}/messages.json from your app instead of from the public CDN.
This is a good fit when you want tighter control over uptime, caching, or deployment environments, or when the app needs to work in restricted or offline-capable environments where the public CDN is not a good dependency.