Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
51ef08a
feat: add custom emoji category support
mjcampagna Mar 16, 2026
ac54fd0
Merge pull request #1 from gluegroups/feat/custom-emoji-support
mjcampagna Mar 16, 2026
ba1c7ad
fix: use id comparison for custom emoji isActive check
mjcampagna Mar 16, 2026
704b6bc
feat: add frequently used emojis support
mjcampagna Mar 16, 2026
f931012
docs: add frequently used emojis and changelog to CUSTOM-EMOJIS.md
mjcampagna Mar 16, 2026
ee35112
feat: add configurable frequentlyLabel prop, bump to 0.3.4
mjcampagna Mar 18, 2026
69e817f
fix: revert package.json name, version, and repository to upstream va…
mjcampagna Mar 18, 2026
410b9bb
fix: use CSS style for img sizing in docs example
mjcampagna Mar 18, 2026
0fc0c4c
refactor: isolate custom emoji logic into dedicated files
mjcampagna Mar 25, 2026
074c060
docs: update AGENTS.md with current architecture and reversal guide
mjcampagna Mar 25, 2026
ecb609f
docs: note searchCustomEmojis mirrors upstream searchEmojis scoring
mjcampagna Mar 25, 2026
57d2d4f
feat: add searchLabel prop for unified cross-category search results
mjcampagna Mar 25, 2026
3d52acc
feat: replace searchLabel gate with explicit unifiedSearch prop
mjcampagna Mar 25, 2026
c1f6411
feat: export scoreEmoji for use by consumers
mjcampagna Mar 25, 2026
c88ec05
test: add coverage for custom emoji additions
mjcampagna Mar 26, 2026
5e9093d
fix: update sameEmojiPickerEmoji to compare custom emojis by id
mjcampagna Mar 26, 2026
61f7221
fix: guard undefined in sameEmojiPickerEmoji before id/emoji checks
mjcampagna Mar 26, 2026
ab889c1
docs: update CUSTOM-EMOJIS.md with unifiedSearch, searchLabel, scoreE…
mjcampagna Mar 26, 2026
63df20c
chore: remove AGENTS.md (fork-internal documentation)
mjcampagna Mar 26, 2026
e535648
feat: remove scoreEmoji export
mjcampagna Mar 26, 2026
6590663
chore: remove PR-32-DESCRIPTION.md (local reference only)
mjcampagna Mar 26, 2026
19862a2
feat: add shortcode search support to unified emoji search
mjcampagna Mar 30, 2026
9e5ea9e
docs: fix package name reference in CUSTOM-EMOJIS.md
mjcampagna Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions CUSTOM-EMOJIS.md
Comment thread
mjcampagna marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Custom Emoji Support

## Overview

The `custom` prop on `<EmojiPicker.Root>` lets you inject image-based emoji categories into the picker. Custom categories appear after the standard Unicode emoji categories and are searchable by label and tags. The `frequently` prop adds a "Frequently Used" category at the top of the picker, supporting both native and custom emojis.

## Usage

Pass custom categories via the `custom` prop on `<EmojiPicker.Root>` and provide a custom `Emoji` component via `<EmojiPicker.List>` to render images:

```tsx
const customCategories = [
{
id: "team",
label: "Team",
emojis: [
{ id: "shipit", label: "Ship It", url: "/emojis/shipit.png", tags: ["ship", "deploy"] },
{ id: "lgtm", label: "Looks Good To Me", url: "/emojis/lgtm.png", tags: ["approve"] },
],
},
];

function MyEmojiPicker() {
return (
<EmojiPicker.Root
custom={customCategories}
onEmojiSelect={(emoji) => {
if (emoji.url) {
// Handle custom image emoji
console.log("Custom emoji:", emoji.id, emoji.url);
} else {
// Handle standard Unicode emoji
console.log("Emoji:", emoji.emoji);
}
}}
>
<EmojiPicker.Search />
<EmojiPicker.Viewport>
<EmojiPicker.List
components={{
Emoji: ({ emoji, ...props }) => (
<button {...props}>
{emoji.url ? (
<img src={emoji.url} alt={emoji.label} style={{ width: "1em", height: "1em" }} />
) : (
emoji.emoji
)}
</button>
),
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
);
}
```

## Search

Custom emojis are searchable using the same scoring as standard emojis:

- **Label match**: +10 points
- **Tag match**: +1 point per tag

Results are sorted by score descending and filtered when using `<EmojiPicker.Search>`.

### Unified Search

By default, search results are displayed within their original categories (standard Unicode categories and custom categories separately). Enable `unifiedSearch` to merge all results — native and custom — into a single ranked list sorted by relevance score:

```tsx
<EmojiPicker.Root
custom={customCategories}
unifiedSearch
searchLabel="Results"
onEmojiSelect={handleSelect}
>
{/* ... */}
</EmojiPicker.Root>
```

- **`unifiedSearch`** (`boolean`, default `false`): When `true`, all search results are combined into one category ranked by score. Has no effect when there is no active search query.
- **`searchLabel`** (`string`, optional): Label for the unified results category header. Defaults to `""` if omitted.

## `onEmojiSelect` Handling

The `emoji` object passed to `onEmojiSelect` differs for custom vs standard emojis:

| Field | Standard Emoji | Custom Emoji |
| ------- | -------------- | ------------ |
| `emoji` | `"😀"` | `undefined` |
| `label` | `"Grinning"` | `"Ship It"` |
| `url` | `undefined` | `"/emojis/shipit.png"` |
| `id` | `undefined` | `"shipit"` |

Check for `emoji.url` to distinguish between the two.

## Frequently Used Emojis

Pass an array of `EmojiPickerEmoji` objects via the `frequently` prop to display a "Frequently Used" category at the top of the picker. Supports both native and custom emojis. The category is hidden during search.

```tsx
<EmojiPicker.Root
frequently={[
{ emoji: "👍", label: "Thumbs Up" },
{ emoji: "❤️", label: "Red Heart" },
{ id: "shipit", label: "Ship It", url: "/emojis/shipit.png" },
]}
frequentlyLabel="Favorites"
onEmojiSelect={handleSelect}
>
{/* ... */}
</EmojiPicker.Root>
```

The consumer is responsible for tracking and persisting frequency data — frimousse does not manage localStorage or usage counts internally.

## Prop Reference

All custom emoji props are added to `<EmojiPicker.Root>`:

| Prop | Type | Default | Description |
| ----------------- | --------------------- | ------------------- | ----------- |
| `custom` | `CustomCategory[]` | — | Custom image-based emoji categories, appended after standard categories. |
| `frequently` | `EmojiPickerEmoji[]` | — | Emojis to show in a "Frequently Used" category at the top. Hidden during search. |
| `frequentlyLabel` | `string` | `"Frequently Used"` | Label for the frequently used category header. |
| `unifiedSearch` | `boolean` | `false` | When `true`, merges all search results into one ranked list. |
| `searchLabel` | `string` | `""` | Category header label when `unifiedSearch` is active. |

## Types

```ts
type CustomEmoji = {
id: string;
label: string;
url: string;
tags?: string[];
};

type CustomCategory = {
id: string;
label: string;
emojis: CustomEmoji[];
};
```

Both are exported from `frimousse`.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 29 additions & 7 deletions src/components/emoji-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
useState,
} from "react";
import { EMOJI_FONT_FAMILY } from "../constants";
import type { CustomEmojiRootProps } from "../custom-emoji-types";
import { getEmojiData, validateLocale, validateSkinTone } from "../data/emoji";
import { getEmojiPickerData } from "../data/emoji-picker";
import { loadShortcodes } from "../data/shortcodes";
import { useActiveEmoji, useSkinTone } from "../hooks";
import {
$activeEmoji,
Expand Down Expand Up @@ -57,6 +59,7 @@ import type {
WithAttributes,
} from "../types";
import { shallow } from "../utils/compare";
import { isSameEmoji } from "../utils/emoji-identity";
import { noop } from "../utils/noop";
import { requestIdleCallback } from "../utils/request-idle-callback";
import { useCreateStore, useSelector, useSelectorKey } from "../utils/store";
Expand All @@ -66,7 +69,15 @@ import { useStableCallback } from "../utils/use-stable-callback";
function EmojiPickerDataHandler({
emojiVersion,
emojibaseUrl,
}: Pick<EmojiPickerRootProps, "emojiVersion" | "emojibaseUrl">) {
custom,
frequently,
frequentlyLabel,
unifiedSearch,
searchLabel,
}: Pick<
EmojiPickerRootProps & CustomEmojiRootProps,
"emojiVersion" | "emojibaseUrl" | "custom" | "frequently" | "frequentlyLabel" | "unifiedSearch" | "searchLabel"
>) {
const [emojiData, setEmojiData] = useState<EmojiData | undefined>(undefined);
const store = useEmojiPickerStore();
const locale = useSelectorKey(store, "locale");
Expand All @@ -78,6 +89,8 @@ function EmojiPickerDataHandler({
const controller = new AbortController();
const signal = controller.signal;

loadShortcodes(emojibaseUrl, emojiVersion).catch(() => {});

getEmojiData({ locale, emojiVersion, emojibaseUrl, signal })
.then((data) => {
setEmojiData(data);
Expand All @@ -103,12 +116,12 @@ function EmojiPickerDataHandler({
store
.get()
.onDataChange(
getEmojiPickerData(emojiData, columns, skinTone, search),
getEmojiPickerData(emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel),
);
},
{ timeout: 100 },
);
}, [emojiData, columns, skinTone, search]);
}, [emojiData, columns, skinTone, search, custom, frequently, frequentlyLabel, unifiedSearch, searchLabel]);

return null;
}
Expand Down Expand Up @@ -136,13 +149,18 @@ function EmojiPickerDataHandler({
* </EmojiPicker.Root>
* ```
*/
const EmojiPickerRoot = forwardRef<HTMLDivElement, EmojiPickerRootProps>(
const EmojiPickerRoot = forwardRef<HTMLDivElement, EmojiPickerRootProps & CustomEmojiRootProps>(
(
{
locale = "en",
columns = 9,
skinTone = "none",
onEmojiSelect = noop,
custom,
frequently,
frequentlyLabel,
unifiedSearch,
searchLabel,
emojiVersion,
emojibaseUrl,
onFocusCapture,
Expand Down Expand Up @@ -472,6 +490,11 @@ const EmojiPickerRoot = forwardRef<HTMLDivElement, EmojiPickerRootProps>(
<EmojiPickerDataHandler
emojibaseUrl={emojibaseUrl}
emojiVersion={emojiVersion}
custom={custom}
frequently={frequently}
frequentlyLabel={frequentlyLabel}
unifiedSearch={unifiedSearch}
searchLabel={searchLabel}
/>
{children}
</EmojiPickerStoreProvider>
Expand Down Expand Up @@ -840,9 +863,8 @@ const EmojiPickerListEmoji = memo(
rowIndex: number;
} & Pick<EmojiPickerListComponents, "Emoji">) => {
const store = useEmojiPickerStore();
const isActive = useSelector(
store,
(state) => $activeEmoji(state)?.emoji === emoji.emoji,
const isActive = useSelector(store, (state) =>
isSameEmoji($activeEmoji(state), emoji),
);

const handleSelect = useCallback(() => {
Expand Down
61 changes: 61 additions & 0 deletions src/custom-emoji-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { EmojiPickerEmoji, EmojiPickerRootProps } from "./types";

export type CustomEmoji = {
id: string;
label: string;
url: string;
tags?: string[];
};

export type CustomCategory = {
id: string;
label: string;
emojis: CustomEmoji[];
};

/**
* Additional root props for custom emoji support.
* Intersected with the upstream EmojiPickerRootProps at the component level.
*/
export interface CustomEmojiRootProps {
/**
* Custom emoji categories to append to the picker.
*/
custom?: CustomCategory[];

/**
* Frequently used emojis to display at the top of the picker.
* Supports both native emojis (with `emoji` field) and custom emojis (with `url` and `id` fields).
*/
frequently?: EmojiPickerEmoji[];

/**
* The label for the frequently used category header.
*
* @default "Frequently Used"
*/
frequentlyLabel?: string;

/**
* When true, search results from both native and custom emojis are merged
* into a single flat category sorted by relevance, instead of being
* displayed within their original categories.
*
* @default false
*/
unifiedSearch?: boolean;

/**
* The label for the unified search results category header.
* Only used when `unifiedSearch` is true.
*
* @default ""
*/
searchLabel?: string;
}

/**
* Full root props type, including custom emoji extensions.
* Re-exported from index.ts as EmojiPickerRootProps to shadow the upstream type.
*/
export type AugmentedEmojiPickerRootProps = EmojiPickerRootProps & CustomEmojiRootProps;
Loading