Emoji picker
Search, categories, skin tones and the arrow keys over frimousse, its emoji list served by the app's own route rather than a CDN.
The emoji list comes from the app itself. The item installs a route at app/emoji/[file]/route.ts that answers /emoji/en.json with frimousse's data built from the emojibase-data package — built once with the app, and kept by the browser for a day, then shown stale for a week more while it asks again, because the address carries no version and an upgrade of either package can change what it holds — and the picker asks for it with one same-origin fetch. frimousse's own resolver would fetch the list from a CDN and keep copies in localStorage and sessionStorage; this one asks no other server and writes nothing to the reader's browser. To mount the route yourself, re-export GET and generateStaticParams from it and nothing more: the one param it lists has Next build the list with the app, with Cache Components on or off, and an app with Cache Components refuses a segment config such as dynamic outright. Before the list is shown it is trimmed to what the platform draws — emojis newer than the newest version a canvas probe finds drawn in colour, and country flags where they come out as letters — so the grid has no empty boxes; if the list does not load, the picker says so instead of spinning.
The focus stays in the search field: the arrow keys move an active emoji through the rows (left and right follow the row's direction, so they flip in a right-to-left page), Enter picks it, and frimousse's live region speaks its name as it moves; the list scrolls to keep it in sight. Each category is a heading and each emoji a button named by its label, so a screen reader can also browse to one and press it. The skin-tone button cycles through the five tones and names the next one. onEmojiSelect hands you { emoji, label } and stores nothing; what you do with the character is yours — insert it at the caret, post it as a reaction. EmojiPickerPopover puts the picker behind a button named Insert emoji (or your own trigger), closes on a pick and hands the focus back to the trigger. frimousse is not small: in a page, load the picker with next/dynamic or an import() when the reader first opens it, so the page's first load does not carry it.
Install
npx shadcn@latest add @vibra/emoji-pickerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { EmojiPicker } from "@/components/ui/emoji-picker"
export default function EmojiPickerDemo() {
const [picked, setPicked] = React.useState<{ emoji: string; label: string } | null>(null)
return (
<div className="flex flex-col items-center gap-3">
<EmojiPicker onEmojiSelect={setPicked} className="elev-1 overflow-hidden" />
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{picked ? `Picked ${picked.emoji} ${picked.label}` : "Search, or use the arrow keys and Enter."}
</p>
</div>
)
}In a composer
Behind a button beside a message field, inserting at the caret.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { EmojiPickerPopover } from "@/components/ui/emoji-picker"
import { Input } from "@/components/ui/input"
export default function EmojiPickerPopoverDemo() {
const [message, setMessage] = React.useState("Release notes are out ")
const field = React.useRef<HTMLInputElement>(null)
return (
<form className="flex w-full max-w-md items-center gap-2" onSubmit={(event) => event.preventDefault()}>
<Input ref={field} aria-label="Message #release" value={message} onChange={(event) => setMessage(event.target.value)} />
<EmojiPickerPopover
side="top"
align="end"
onEmojiSelect={({ emoji }) => {
// An input keeps its selection while the picker has the focus, so
// the emoji lands where the caret was, over any text it had selected.
const input = field.current
const start = input?.selectionStart ?? message.length
const end = input?.selectionEnd ?? message.length
setMessage(message.slice(0, start) + emoji + message.slice(end))
}}
/>
<Button type="submit" size="sm">
Send
</Button>
</form>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| onEmojiSelect | (emoji: { emoji: string; label: string }) => void | — | The emoji picked, by click or by Enter, and its name. |
| columns | number | 9 | Emojis to a row. |
| locale | "en" | "en" | The language of the names and the search. The route ships English. |
| dataUrl | string | EMOJI_DATA_ROUTE ("/emoji") | Where the route answers: the picker asks for ${dataUrl}/${locale}.json. Keep it on your own origin. |
| size | "sm" | "default" | "default" | sm is a shorter picker with smaller cells, for a popover. |
| className | string | — | Merged onto the root; the rest of the div props are spread there too. |
| EmojiPickerPopover | EmojiPickerProps & { trigger?: React.ReactElement; side?: "top" | "bottom"; align?: "start" | "center" | "end"; label?: string } | — | The picker in a popover. label ("Insert emoji") names the default trigger and the popup; it closes on a pick and returns the focus to the trigger. |
| toEmojiData / supportedOnly | (emojis, messages, locale) => EmojiData / (data: EmojiData, emojiVersion?: number) => EmojiData | — | From emoji-picker/data: frimousse's data built from Emojibase, as the route does it, and the same data trimmed to an Emoji version — or, without one, to what this browser draws. |
Dependencies
Source
"use client"
import * as React from "react"
import {
EmojiPicker as Frimousse,
type EmojiData,
type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps,
type Locale,
useActiveEmoji,
} from "frimousse"
import { SmilePlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Spinner } from "@/components/ui/spinner"
import { supportedOnly } from "./data"
/** Where the emoji route is mounted: the picker asks `${EMOJI_DATA_ROUTE}/<locale>.json` of its own origin. */
export const EMOJI_DATA_ROUTE = "/emoji"
export type EmojiPickerProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
/** The emoji picked, by click or by Enter, and its name. */
onEmojiSelect: (emoji: { emoji: string; label: string }) => void
locale?: "en"
/** Emojis to a row. */
columns?: number
/** Where the route answers; the app's own `/emoji` unless you mounted it elsewhere. */
dataUrl?: string
size?: "sm" | "default"
}
const NO_TONES: EmojiData["skinTones"] = { light: "", "medium-light": "", medium: "", "medium-dark": "", dark: "" }
function CategoryHeader({ category, className, ...props }: EmojiPickerListCategoryHeaderProps) {
return (
<div {...props} className={cn("bg-popover px-2 pt-2 pb-1", className)}>
<h3 className="text-xs font-medium text-muted-foreground">{category.label}</h3>
</div>
)
}
/**
* A row of emojis, laid out by frimousse. Not an ARIA row: frimousse numbers
* its rows and cells from 0 where ARIA counts from 1, and a grid may not hold
* the category headings — so the list is headings and named buttons, and
* frimousse's live region speaks the active emoji as the arrow keys move it.
*/
function Row({ className, ...props }: EmojiPickerListRowProps) {
return <div {...props} role={undefined} aria-rowindex={undefined} className={cn("px-1", className)} />
}
/** One emoji: a button named by its label, never a tab stop of its own — the focus stays in the search field. */
function Emoji({ emoji, className, ...props }: EmojiPickerListEmojiProps) {
return (
<button
{...props}
type="button"
role={undefined}
aria-colindex={undefined}
aria-selected={undefined}
aria-label={emoji.label}
className={cn(
// Kept clear of the sticky category heading when the arrow keys bring it into view.
"flex size-8 scroll-mt-[calc(var(--frimousse-category-header-height,1.75rem)+0.25rem)] scroll-mb-1 items-center justify-center rounded-md text-xl leading-none data-active:bg-muted group-data-[size=sm]/emoji-picker:size-7 group-data-[size=sm]/emoji-picker:text-lg",
className
)}
>
{emoji.emoji}
</button>
)
}
/**
* Brings the active emoji into view when the arrow keys move it — frimousse
* finds the row by the ARIA index this picker leaves off. A pointer moving
* over the list never scrolls it.
*/
function ActiveInView({ viewport, moved }: { viewport: React.RefObject<HTMLDivElement | null>; moved: React.RefObject<"keyboard" | "pointer"> }) {
const active = useActiveEmoji()
React.useLayoutEffect(() => {
if (!active || moved.current !== "keyboard") return
viewport.current?.querySelector<HTMLElement>("[data-active]")?.scrollIntoView({ block: "nearest" })
}, [active, moved, viewport])
return null
}
/**
* An emoji picker on frimousse: a search field, the emojis by category, a
* skin tone and the arrow keys, with the data from the app's own origin
* (the route this item installs) rather than a CDN, and nothing kept in the
* browser's storage.
*/
function EmojiPicker({
onEmojiSelect,
locale = "en",
columns = 9,
dataUrl = EMOJI_DATA_ROUTE,
size = "default",
className,
onKeyDownCapture,
onPointerMoveCapture,
...props
}: EmojiPickerProps) {
const [failed, setFailed] = React.useState(false)
const viewport = React.useRef<HTMLDivElement>(null)
const moved = React.useRef<"keyboard" | "pointer">("pointer")
const resolve = React.useCallback(
(requested: Locale, { emojiVersion, signal }: { emojiVersion?: number; signal?: AbortSignal }) =>
fetch(`${dataUrl}/${requested}.json`, { signal })
.then((response) => {
if (!response.ok) throw new Error(`${response.status}`)
return response.json() as Promise<EmojiData>
})
.then((data) => supportedOnly(data, emojiVersion))
.catch((error: unknown) => {
if (signal?.aborted) throw error
// An empty list and a line that says why, rather than a spinner that never stops.
setFailed(true)
return { locale: requested, emojis: [], categories: [], skinTones: NO_TONES }
}),
[dataUrl]
)
return (
<Frimousse.Root
data-slot="emoji-picker"
data-size={size}
locale={locale}
columns={columns}
resolveEmojiData={resolve}
onEmojiSelect={(picked) => onEmojiSelect({ emoji: picked.emoji, label: picked.label })}
className={cn(
"group/emoji-picker isolate flex w-fit max-w-full flex-col bg-popover text-popover-foreground",
size === "sm" ? "h-80" : "h-96",
className
)}
onKeyDownCapture={(event) => {
onKeyDownCapture?.(event)
moved.current = "keyboard"
// frimousse reads ArrowLeft as the previous emoji, which in a
// right-to-left row is the one to the right: it is handed the other
// arrow there, on the document it listens to.
if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && event.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl") {
event.preventDefault()
document.dispatchEvent(new KeyboardEvent("keydown", { key: event.key === "ArrowLeft" ? "ArrowRight" : "ArrowLeft", bubbles: true }))
}
}}
onPointerMoveCapture={(event) => {
onPointerMoveCapture?.(event)
moved.current = "pointer"
}}
{...props}
>
<ActiveInView viewport={viewport} moved={moved} />
<div className="p-2 pb-1">
<Frimousse.Search
aria-label="Search emoji"
placeholder="Search emoji"
className="h-8 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 text-base transition-colors focus-ring placeholder:text-muted-foreground md:text-sm"
/>
</div>
<Frimousse.Viewport ref={viewport} className="relative flex-1 outline-hidden">
<Frimousse.Loading className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<Spinner aria-label="Loading emoji" />
</Frimousse.Loading>
<Frimousse.Empty className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm text-muted-foreground">
{failed ? "The emoji list did not load." : "No emoji found"}
</Frimousse.Empty>
<Frimousse.List
// Headings and named buttons, not frimousse's grid: see Row.
role="group"
aria-label="Emoji"
aria-colcount={undefined}
aria-rowcount={undefined}
className="pb-1 select-none"
components={{ CategoryHeader, Row, Emoji }}
/>
</Frimousse.Viewport>
<div className="flex h-11 items-center justify-between gap-2 border-t px-2">
<Frimousse.ActiveEmoji>
{({ emoji }) =>
emoji ? (
<span className="flex min-w-0 items-center gap-2">
<span className="text-xl leading-none">{emoji.emoji}</span>
<span className="truncate text-xs text-muted-foreground">{emoji.label}</span>
</span>
) : (
<span className="truncate text-xs text-muted-foreground">Arrow keys move, Enter picks</span>
)
}
</Frimousse.ActiveEmoji>
<Frimousse.SkinToneSelector
aria-label="Skin tone"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-lg leading-none transition-colors focus-ring hover:bg-muted"
/>
</div>
</Frimousse.Root>
)
}
export type EmojiPickerPopoverProps = Omit<EmojiPickerProps, "size"> & {
/** The control that opens it; a ghost icon button named by `label` when left out. */
trigger?: React.ReactElement
side?: "top" | "bottom"
align?: "start" | "center" | "end"
/** Names the trigger and the popup. */
label?: string
}
/**
* The picker in a popover: opens from its trigger, closes on a pick and
* hands the focus back to the trigger, so a composer's text field is one
* Shift+Tab away.
*/
function EmojiPickerPopover({ trigger, side = "bottom", align = "start", label = "Insert emoji", onEmojiSelect, ...props }: EmojiPickerPopoverProps) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
trigger ?? (
<Button type="button" variant="ghost" size="icon-sm" aria-label={label} className="text-muted-foreground">
<SmilePlusIcon aria-hidden="true" />
</Button>
)
}
/>
<PopoverContent side={side} align={align} aria-label={label} className="w-auto gap-0 overflow-hidden p-0">
<EmojiPicker
size="sm"
onEmojiSelect={(emoji) => {
onEmojiSelect(emoji)
setOpen(false)
}}
{...props}
/>
</PopoverContent>
</Popover>
)
}
export { EmojiPicker, EmojiPickerPopover }import type { Emoji, MessagesDataset } from "emojibase"
import type { EmojiData } from "frimousse"
/**
* The emoji picker's data, built from the `emojibase-data` package on the
* app's own server and trimmed in the browser to what the platform draws.
* No React and no fetch in here: the route builds with `toEmojiData`, the
* picker's resolver filters with `supportedOnly`.
*/
/** One entry of `emojibase-data/<locale>/data.json`. */
export type EmojibaseEmoji = Emoji
/** `emojibase-data/<locale>/messages.json`: the group, subgroup and skin-tone names. */
export type EmojibaseMessages = MessagesDataset
type SkinToneName = keyof EmojiData["skinTones"]
/** Emojibase numbers a skin tone 1–5; frimousse names it. */
const SKIN_TONES: readonly SkinToneName[] = ["light", "medium-light", "medium", "medium-dark", "dark"]
const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1)
/** The component group: skin-tone swatches and hair parts, which a picker never offers on their own. */
const COMPONENT = "component"
/**
* frimousse's EmojiData, as its own default resolver builds it from
* Emojibase: the categories from the groups, each emoji's label capitalised,
* its tags, its five skin tones and any multi-person toned forms as aliases,
* and country flags marked so a platform that draws them as letters can drop
* them. Entries outside every group are left out, and — where frimousse's
* filter falls short of its own comment — so are the component group's.
*/
export function toEmojiData(emojis: EmojibaseEmoji[], messages: EmojibaseMessages, locale: string): EmojiData {
const component = messages.groups.find((group) => group.key === COMPONENT)?.order
const flags = messages.subgroups.find((subgroup) => subgroup.key === "country-flag" || subgroup.key === "subdivision-flag")
return {
locale,
categories: messages.groups
.filter((group) => group.key !== COMPONENT)
.map((group) => ({ index: group.order, label: capitalize(group.message) })),
emojis: emojis
.filter((emoji) => emoji.group !== undefined && emoji.group !== component)
.map((emoji) => {
const toned = emoji.skins?.filter((skin) => typeof skin.tone === "number")
const aliases = emoji.skins?.filter((skin) => Array.isArray(skin.tone)).map((skin) => skin.emoji) ?? []
return {
emoji: emoji.emoji,
category: emoji.group!,
label: capitalize(emoji.label),
version: emoji.version,
tags: emoji.tags ?? [],
...(flags && emoji.subgroup === flags.order ? { countryFlag: true as const } : {}),
...(toned
? { skins: Object.fromEntries(toned.map((skin) => [SKIN_TONES[(skin.tone as number) - 1], skin.emoji])) as EmojiData["skinTones"] }
: {}),
...(aliases.length > 0 ? { aliases } : {}),
}
}),
skinTones: Object.fromEntries(messages.skinTones.map((tone) => [tone.key, capitalize(tone.message)])) as EmojiData["skinTones"],
}
}
const PROBE_SIZE = 2
/**
* Whether this platform draws `emoji` as an emoji: one glyph, not its parts
* side by side, and in its own colours — a missing glyph, a box or a letter,
* is painted in whatever colour the text is.
*/
function draws(context: CanvasRenderingContext2D, emoji: string): boolean {
if (context.measureText(emoji).width >= PROBE_SIZE * 2) return false
const paint = (colour: string) => {
context.clearRect(0, 0, PROBE_SIZE, PROBE_SIZE)
context.fillStyle = colour
context.fillText(emoji, 0, 0)
return context.getImageData(0, 0, PROBE_SIZE, PROBE_SIZE).data
}
const blue = paint("#00f")
const red = paint("#f00")
for (let index = 0; index < blue.length; index += 4) {
if (blue[index] !== red[index] || blue[index + 1] !== red[index + 1] || blue[index + 2] !== red[index + 2]) return false
}
return true
}
/** The newest Emoji version the platform draws, and whether it draws country flags; null where nothing can tell. */
function drawable(emojis: EmojiData["emojis"]): { version: number; countryFlags: boolean } | null {
let context: CanvasRenderingContext2D | null = null
try {
context = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d", { willReadFrequently: true })
} catch {
context = null
}
if (!context) return null
context.canvas.width = PROBE_SIZE
context.canvas.height = PROBE_SIZE
context.font = `${PROBE_SIZE}px sans-serif`
context.textBaseline = "middle"
// The first emoji of each version stands for it, newest first.
const samples = new Map<number, string>()
for (const emoji of emojis) if (!samples.has(emoji.version)) samples.set(emoji.version, emoji.emoji)
const versions = [...samples.keys()].sort((a, b) => b - a)
const version = versions.find((candidate) => draws(context, samples.get(candidate)!)) ?? versions[0] ?? 0
return { version, countryFlags: draws(context, "🇪🇺") }
}
/**
* The data without what the platform cannot draw, so the picker shows no
* empty boxes: emojis newer than `emojiVersion` — or, without one, newer than
* the newest version this browser draws — and country flags where it draws
* them as letters. Where nothing can tell (on a server), everything is kept.
*/
export function supportedOnly(data: EmojiData, emojiVersion?: number): EmojiData {
const platform = emojiVersion === undefined ? drawable(data.emojis) : null
const newest = emojiVersion ?? platform?.version ?? Number.POSITIVE_INFINITY
const flags = platform?.countryFlags ?? true
return { ...data, emojis: data.emojis.filter((emoji) => emoji.version <= newest && (flags || !emoji.countryFlag)) }
}import emojis from "emojibase-data/en/data.json"
import messages from "emojibase-data/en/messages.json"
import { toEmojiData } from "@/components/ui/emoji-picker/data"
/**
* The emoji picker's data, from the app's own origin: `GET /emoji/en.json`
* answers with frimousse's EmojiData built from the `emojibase-data` package,
* so the picker asks no other server for anything. Installed at
* `app/emoji/[file]/route.ts`. Built once, with the app — but its address
* carries no version, and what it holds is frimousse's shape, which an
* upgrade of either package can change. So a browser keeps it for a day, then
* for a week more shows the copy it has while it asks for the new one: a
* returning visitor has the new list on the next visit after a day at most.
*
* The one param generateStaticParams lists is what has Next build it with the
* app, with Cache Components on or off. No segment config: an app with Cache
* Components — as create-next-app starts one — refuses `dynamic` outright.
*/
export function generateStaticParams() {
return [{ file: "en.json" }]
}
export async function GET(_request: Request, context: { params: Promise<{ file: string }> }) {
const { file } = await context.params
// One locale ships; any other name — a path, another language — is not here.
if (file !== "en.json") return new Response("Not found", { status: 404 })
return Response.json(toEmojiData(emojis, messages, "en"), {
headers: { "Cache-Control": "public, max-age=86400, stale-while-revalidate=604800" },
})
}