Skip to contentVibraUI
Inputs & filters

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-picker

Needs the @vibra registry in your components.json — set it up once.

Examples

In a composer

Behind a button beside a message field, inserting at the caret.

Props

PropTypeDefaultDescription
onEmojiSelect(emoji: { emoji: string; label: string }) => void—The emoji picked, by click or by Enter, and its name.
columnsnumber9Emojis to a row.
locale"en""en"The language of the names and the search. The route ships English.
dataUrlstringEMOJI_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.
classNamestring—Merged onto the root; the rest of the div props are spread there too.
EmojiPickerPopoverEmojiPickerProps & { 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

components/ui/emoji-picker/index.tsx
"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 }
components/ui/emoji-picker/data.ts
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)) }
}
app/emoji/[file]/route.ts
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" },
  })
}