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

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/frimousse

Migrate

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 identity
  • label: human-readable display name
  • shortcode: optional canonical display-oriented :shortcode: token
  • aliases: alternate typed forms or names
  • keywords: search-oriented descriptors
  • tags: broader discovery hints
  • imageUrl: presentation data
  • data: 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:

  1. 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.

  1. 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 label as your stored identity
  • do not use tags as a substitute for canonical names
  • do not treat aliases as arbitrary metadata
  • do not put presentation-only concerns into data when a first-class field already exists

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
  • resultsLabel names 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.terms entries are primary name-like signals
  • supplemental label, aliases, and exact canonical id matches 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 -> onItemSelect
  • useActiveEmoji -> useActiveItem
  • EmojiPicker.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 1 to the item’s score
  • older scores decay over time before the next increment
  • ranking is by decayed score first, then lastUsedAt, then total uses
  • the default score half-life is 30 days

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

  • SupplementalEmoji in EmojiPicker.List only needs to be customized if you want a different renderer for supplemental items.
  • useActiveEmoji and EmojiPicker.ActiveEmoji stay native-only.
  • useActiveItem and EmojiPicker.ActiveItem reflect supplemental items too.
  • Companion packages like @slithy/emoji-transforms can 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

Install the package when you want richer native search terms or native shortcode metadata.

pnpm add @slithy/emoji-transforms

Use 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

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.

Select one of the mapped examples below to show its canonical shortcode in the footer.
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 canonical id matches 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.

Unified search can mix native shortcode aliases with shortcode-first custom emoji names.
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-compat

Use 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.ts

Use 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-transforms enriches native search and shortcode metadata. It does not change the picker component surface.
  • @slithy/emoji-compat handles 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 in aliases.
  • If you already have your own emoji metadata pipeline, you can build the same search.native.terms shape and compat-map input shape yourself.