Emoji Picker
A full emoji panel — category nav, search, skin tone, recents and a windowed, arrow-key-navigable grid, inline or portalled.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/emoji-picker.jsonPrompt
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
useDeferredValueof 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.bodyat fixed coordinates, so anoverflow: hiddencomposer card cannot clip it; only genuinely scrollable ancestors count as boundaries, because a decorative clip is something the portal has already escaped.
Filter Bar
A composable filter bar for the toolbar above a table: an add-filter menu, one editable chip per active filter, and portalled editors for select, multiselect, date range, text and boolean fields.
Form Field
A label + control wrapper that wires id, aria-describedby, invalid, required, disabled and a character counter onto whatever control you put inside it.