Inputs

Emoji Picker

A full emoji panel — category nav, search, skin tone, recents and a windowed, arrow-key-navigable grid, inline or portalled.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Cat, Clock, Coffee, Hand, Hash, Lightbulb, Plane, Search, Smile, Trophy, X } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Data model
 * ------------------------------------------------------------------ */

export type EmojiGroupId =
  | "smileys"
  | "people"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/emoji-picker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "EmojiPicker" component (lucide-react
Cat, Clock, Coffee, Hand, Hash, Lightbulb, Plane, Search, Smile, Trophy, X).
No Radix and no popover library — the panel is drawn, placed and driven by
the component itself.

Contract
- export type EmojiGroupId = "smileys" | "people" | "animals" | "food" |
  "activity" | "travel" | "objects" | "symbols".
- export interface EmojiDatum { char; name; keywords: string[]; group:
  EmojiGroupId; skinToneBase?: boolean }. `name` is the option's aria-label
  and the preview-bar caption; `keywords` is mixed English/Chinese and is
  searched alongside `name`; `skinToneBase` marks the emoji that accept a
  Fitzpatrick modifier.
- export interface EmojiSelection { char; name; group } — `char` already
  carries the active skin tone, so the consumer inserts it verbatim.
- export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5 (0 = default yellow, 1-5 =
  U+1F3FB..U+1F3FF).
- export function applySkinTone(char, tone): splices the modifier in after
  the FIRST code point, not at the end — a ZWJ sequence must be recoloured
  on the person, not on the trailing gender sign — and drops a VS-16 sitting
  immediately after a text-default base, because the modifier already forces
  emoji presentation and "victory hand + VS16 + tone" renders as a text-style
  hand plus a stray swatch in several fonts.
- export const EMOJI_DATA (a curated 554-entry table, 8 groups) and
  EMOJI_GROUPS ({ id, label, Icon }[], the category bar's order).
- export default + named forwardRef component; the ref points at the panel
  div. Props extend HTMLAttributes<HTMLDivElement> minus onSelect:
  - onSelect(emoji: EmojiSelection) — required; fires on click and on Enter.
  - trigger?: ReactNode — the PRESENCE of this prop is the mode switch.
    Omitted: the panel renders inline and open / onOpenChange do nothing.
    Given: the panel is portalled and the trigger toggles it. A valid element
    is cloned with aria-expanded / aria-haspopup="dialog" / aria-controls /
    data-state and a wrapped onClick that yields if the original called
    preventDefault; anything else (a string, a number) is wrapped in a
    default icon button.
  - open?/onOpenChange? (floating only), recent?: string[] /
    onRecentChange?, skinTone?: SkinTone / onSkinToneChange? — each
    controlled-or-uncontrolled through one shared hook that keeps the change
    callback in a ref, so an inline arrow function never re-runs an effect.
  - columns? (default 8, clamped to 1-16 — it divides in the grid maths and
    also caps the recents row), searchPlaceholder?, defaultQuery?
    (re-applied on every open), emojis? (replaces EMOJI_DATA), className.

Behavior
- Windowing, not virtualisation: every cell is exactly 36px and every section
  header exactly 28px, so a section's height is HEADER + ceil(count/columns) *
  CELL — arithmetic, never measured. Only the sections intersecting the scroll
  window (± 240px overscan) mount their buttons; the rest render as
  reserved-height blanks, which keeps ~120 buttons in the DOM instead of 554
  and makes the scrollbar honest from the first frame. Scroll offsets are
  quantised to 100px before entering state, so scrolling re-renders about once
  per 100px instead of once per frame.
- Not one cell carries a listener. Click, pointerover, focus, blur and keydown
  are read off the scroll container and resolved through a data-emoji-index
  attribute — 554 cells cost 554 attributes, not 2770 closures.
- Search filters on name + keywords, every term must match, and the list
  renders from a useDeferredValue copy of the query: the input never lags a
  keystroke even when the commit re-mounts a screenful of cells, and there is
  no debounce timer to clean up.
- Recents are the consumer's to persist. The component stores BASE characters
  (no tone), dedupes them, caps the row at `columns`, hands the new list to
  onRecentChange after every pick, and renders unknown characters (an older
  dataset, a custom pick) with the character as its own name rather than
  dropping them. Nothing is read from localStorage during render.
- Keyboard: ArrowLeft/Right move by one, ArrowUp/Down by `columns`, Home/End
  jump to the first/last cell of the whole flat list, ArrowUp out of the first
  row returns to the search box, ArrowDown / Enter in the search box enters the
  grid / picks the active cell. Escape closes a floating panel (and
  stopPropagation, so a picker inside a dialog closes itself only) and clears
  the query when inline. Moving focus into a section that is still a blank
  reveals it first and completes the focus on the next render; moving focus to
  the index that is ALREADY active must not go through that deferral, because
  React skips the render for a no-op state write and the focus request would be
  stranded — that is the ArrowDown-from-the-search-box case on a fresh panel.
- Scrolling to a cell is arithmetic (section.top + header + row * CELL) rather
  than scrollIntoView, which would also scroll every ancestor — including the
  page behind a floating panel.
- Category bar: clicking scrolls the list (behavior "auto" under
  prefers-reduced-motion), scrolling moves the highlight; a category the
  current dataset or filter does not reach is disabled rather than hidden, so
  the bar never reflows under the pointer.
- Skin tone is a radiogroup with roving tabindex: arrows wrap modulo 6,
  Home/End jump to the ends, and the checked radio is the only tab stop. Each
  radio paints the sample hand in its own tone, so the choice is visible and
  not only named.
- Floating placement: the panel is portalled to document.body at position
  fixed, so an overflow-hidden composer card cannot clip it. Boundaries are the
  viewport intersected with the `overflow: auto | scroll` ancestors only — an
  `overflow: hidden` card is a decorative clip the portal already escaped, and
  treating it as a boundary would crush a 416px panel into a 144px card. One
  pass per update: lift our own max-height/max-width, read the natural size,
  restore them (and the scroller's scrollTop, which collapsing the cap resets),
  flip only when the other side is genuinely roomier, cap the height, align to
  the trigger's left edge, clamp into the box. Measurement runs in a
  ResizeObserver callback — observe() fires once immediately and that first
  callback IS the initial measurement, so no effect body ever calls setState
  synchronously. Capture-phase scroll (passive, rAF-throttled, ignoring scrolls
  that came from inside the panel) and resize keep it pinned; an identity guard
  drops unchanged results so the observer's own re-fire is a no-op. Before the
  first layout lands the panel is opacity-0, never visibility:hidden, which
  would make focus() silently fail.
- Opening focuses the search input; closing restores focus to the first
  focusable inside the trigger, but only after checking isConnected — picking
  an emoji usually re-renders the surrounding composer, and focusing a detached
  node drops focus onto the body. A pointerdown or focusin outside dismisses
  the panel (pointerdown, not click, so the panel is gone before the press
  becomes a click on whatever is underneath) and suppresses the focus restore.
- Defensive normalisation: the dataset is deduped by char (duplicate React
  keys make a removal unmount the wrong cell), columns is floored and clamped
  to 1-16, an out-of-range tone falls back to 0, and re-opening resets the
  query through render-phase adjust-state rather than an effect.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the panel,
  border, bg-accent for hover / focus / the checked tone, bg-primary for the
  category underline, text-foreground vs text-muted-foreground for the preview
  caption and inactive categories, ring-ring for focus-visible. No hex, and no
  chart tokens on text.
- ARIA: the scroller is role="listbox"; each section wraps in a
  role="presentation" div (a role-less one there would hide every group from
  the listbox) containing a role="group" labelled by its header; each cell is
  role="option" with the readable name as aria-label and the emoji itself
  aria-hidden. Exactly one option is aria-selected — the roving tab stop. The
  floating panel is role="dialog" aria-label="Emoji picker", deliberately
  non-modal: no focus trap and no scroll lock, because it re-pins itself to its
  trigger while the page scrolls and closes as soon as focus leaves. Inline it
  is role="group". A polite live region reports the match count while a search
  is active.
- The enter animation is one @keyframes emitted through React 19's `<style
  href precedence>` hoisting (deduped across every instance on the page) and is
  disabled by motion-reduce; the hover scale on a cell is a transform with a
  motion-reduce reset. Nothing loops, so nothing needs a timer.
- cn() merges the consumer className last, so className="h-64" really does
  shorten the panel; the panel's width is columns * 36 + 18 with maxWidth 100%.

Customization levers
- The dataset is the biggest lever and the biggest cost. EMOJI_DATA is 554
  entries: ~24 KB of source, ~30 KB minified, ~11 KB gzipped, statically
  imported, so it lands in whatever bundle renders the picker (the rest of the
  component is ~6 KB gzipped). Trim the compact rows() table down to the 100
  emoji your product actually uses, or delete it entirely and feed `emojis`
  from a dynamic import (`const { default: data } = await import(
  "./emoji-data")`) or from emojibase-data's `en/compact.json` mapped into
  EmojiDatum. Nothing except the default prop value reads EMOJI_DATA.
- Density and size: CELL_PX / HEADER_PX are the two numbers the windowing
  maths depends on — change them together with the h-9 / h-7 classes and the
  cell's text-xl, never one without the other. `columns` and the panel's
  h-[26rem] are free to change on their own.
- Which sub-blocks exist: the category bar, the preview + name row and the
  skin-tone radios are independent siblings inside the panel; deleting any of
  them costs nothing else. Dropping the recents section is a matter of not
  passing `recent`.
- Overscan and quantum: OVERSCAN_PX (240) trades DOM size for blank rows while
  flinging; VIEW_QUANTUM (100) trades render count for highlight precision.
  Both are safe to tune; setting either to 0 is not.
- Placement: SIDE_OFFSET, EDGE_MARGIN and MIN_PANEL_HEIGHT are the placement
  knobs. To align the panel's right edge to the trigger instead, change the
  single `left` expression in computeLayout — flipping, capping and clamping
  all read from the same one-pass result.
- Persistence: wrap `recent` / `onRecentChange` in your own store. If that
  store is localStorage, read it through useSyncExternalStore or an effect (a
  render-time read desynchronises hydration) and wrap the write in try/catch —
  Safari private mode and a full quota both throw on setItem.

Concepts

  • Arithmetic windowing — every cell is 36px and every header 28px, so each category's height and offset are computed rather than measured. Only the sections crossing the scroll window mount their buttons; the rest are blanks of exactly the right height, which is why the scrollbar is honest on the first frame and no measurement pass can ever disagree with the layout.
  • Delegated grid — click, hover, focus and keys are read off the single scroll container and resolved through data-emoji-index, so one more emoji costs one more attribute instead of five more closures.
  • Deferred query, immediate input — the search field renders from state while the list renders from useDeferredValue of that state: the keystroke is never blocked by the commit that re-mounts a screenful of cells, and there is no debounce timer to leak.
  • Tone as a splice, not a suffix — a Fitzpatrick modifier belongs immediately after the base code point, so a ZWJ sequence gets recoloured on the person rather than on the trailing gender sign, and a variation selector that would fight the modifier is dropped first.
  • Recents the consumer owns — the panel derives and hands back a deduped list of base characters; where they are stored (memory, localStorage, the server) and whether they survive a reload stays outside the component, which is what lets it read no browser API during render.
  • Portal as the anti-clipping primitive — with a trigger, the panel lives in document.body at fixed coordinates, so an overflow: hidden composer card cannot clip it; only genuinely scrollable ancestors count as boundaries, because a decorative clip is something the portal has already escaped.

On This Page