Emoji Picker
An emoji picker with search, category tabs, a recents row that learns, arrow-key grid navigation and a name preview.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/emoji-picker.json'use client'
import { useMemo, useRef, useState } from 'react'
import './emoji-picker.css'
type E = [string, string]
const CATS: { id: string; icon: string; label: string; items: E[] }[] = [
{ id: 'smileys', icon: '😀', label: 'Smileys', items: [['😀', 'grinning face happy'], ['😂', 'tears of joy laugh'], ['🥲', 'smiling with tear'], ['😊', 'smiling blush'], ['😍', 'heart eyes love'], ['🤔', 'thinking hmm'], ['😴', 'sleeping tired'], ['😎', 'cool sunglasses'], ['🥳', 'party celebrate'], ['😬', 'grimacing awkward'], ['😭', 'crying sob'], ['🙃', 'upside down'], ['🤯', 'mind blown'], ['😅', 'sweat smile relief']] },
{ id: 'gestures', icon: '👍', label: 'Gestures', items: [['👍', 'thumbs up yes'], ['👎', 'thumbs down no'], ['👏', 'clap applause'], ['🙌', 'raised hands hooray'], ['🙏', 'pray thanks please'], ['👋', 'wave hello bye'], ['🤝', 'handshake deal'], ['✌️', 'victory peace'], ['🤞', 'fingers crossed luck'], ['💪', 'muscle strong'], ['👀', 'eyes look'], ['🫡', 'salute'], ['🤌', 'pinched fingers'], ['☝️', 'point up']] },
{ id: 'food', icon: '🍦', label: 'Food', items: [['🍦', 'ice cream sundae'], ['🍕', 'pizza'], ['🌮', 'taco'], ['🍜', 'noodles ramen'], ['🍣', 'sushi'], ['🥐', 'croissant'], ['🍩', 'donut'], ['☕', 'coffee'], ['🍵', 'tea'], ['🍺', 'beer'], ['🥑', 'avocado'], ['🍓', 'strawberry'], ['🍋', 'lemon'], ['🥨', 'pretzel']] },
{ id: 'objects', icon: '💡', label: 'Objects', items: [['💡', 'idea bulb'], ['🔥', 'fire hot lit'], ['❤️', 'red heart love'], ['✅', 'check done'], ['🚀', 'rocket launch ship'], ['🎉', 'tada party'], ['📌', 'pin'], ['🔒', 'lock secure'], ['📈', 'chart up growth'], ['🐛', 'bug'], ['⚡', 'zap fast'], ['🧠', 'brain smart'], ['📎', 'paperclip attach'], ['⭐', 'star']] },
]
const ALL = CATS.flatMap((c) => c.items)
const nameOf = (e: string) => ALL.find((x) => x[0] === e)?.[1].split(' ').slice(0, 2).join(' ') ?? ''
export interface EmojiPickerProps {
onPick?: (emoji: string) => void
columns?: number
recents?: string[]
size?: 'm' | 'l'
fill?: 'dark' | 'light'
className?: string
}
/** Searchable, keyboard-navigable emoji picker with recents. */
export function EmojiPicker({ onPick, columns = 7, recents: r0 = ['👍', '🎉', '🔥'], size = 'm', fill = 'dark', className }: EmojiPickerProps) {
const [q, setQ] = useState('')
const [cat, setCat] = useState('smileys')
const [recents, setRecents] = useState(r0)
const [act, setAct] = useState(0)
const [peek, setPeek] = useState<string | null>(null)
const grid = useRef<HTMLDivElement>(null)
const list: E[] = useMemo(() => {
const s = q.trim().toLowerCase()
if (s) return ALL.filter(([, n]) => n.includes(s))
return CATS.find((c) => c.id === cat)!.items
}, [q, cat])
const pick = (e: string) => { setRecents((r) => [e, ...r.filter((x) => x !== e)].slice(0, columns)); setPeek(e); onPick?.(e) }
const focusAt = (i: number) => { setAct(i); grid.current?.querySelectorAll<HTMLButtonElement>('.sd-emj__e')[i]?.focus() }
const key = (e: React.KeyboardEvent) => {
const d = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[e.key]
if (d) { e.preventDefault(); focusAt(Math.max(0, Math.min(list.length - 1, act + d))) }
}
const shown = peek ?? list[act]?.[0] ?? null
return (
<div className={['sd-emj', className].filter(Boolean).join(' ')} data-fill={fill} data-size={size} style={{ ['--cols' as string]: columns }}>
<div className="sd-emj__top">
<input className="sd-emj__q" placeholder="Search emoji" aria-label="Search emoji" value={q} onChange={(e) => { setQ(e.target.value); setAct(0) }} />
</div>
{!q && (
<div className="sd-emj__tabs" role="tablist" aria-label="Categories">
{CATS.map((c) => <button key={c.id} type="button" role="tab" className="sd-emj__tab" aria-selected={cat === c.id} aria-label={c.label} onClick={() => { setCat(c.id); setAct(0) }}>{c.icon}</button>)}
</div>
)}
<div className="sd-emj__body">
{!q && <><p className="sd-emj__h">Recent</p><div className="sd-emj__grid">{recents.map((e) => <button key={e} type="button" tabIndex={-1} className="sd-emj__e sd-emj__rec" aria-label={nameOf(e)} onClick={() => pick(e)} onPointerEnter={() => setPeek(e)}>{e}</button>)}</div></>}
<p className="sd-emj__h" aria-live="polite">{q ? `${list.length} result${list.length === 1 ? '' : 's'}` : CATS.find((c) => c.id === cat)!.label}</p>
{list.length ? (
<div ref={grid} className="sd-emj__grid" role="group" aria-label="Emoji" onKeyDown={key}>
{list.map(([e, n], i) => (
<button key={e} type="button" className="sd-emj__e" tabIndex={i === act ? 0 : -1} data-act={i === act || undefined} aria-label={n.split(' ').slice(0, 2).join(' ')}
onClick={() => { setAct(i); pick(e) }} onFocus={() => { setAct(i); setPeek(null) }} onPointerEnter={() => setPeek(e)}>{e}</button>
))}
</div>
) : <p className="sd-emj__none">No emoji for “{q}”</p>}
</div>
<div className="sd-emj__foot" aria-hidden>{shown ? <><span>{shown}</span><span>{nameOf(shown)}</span></> : <span />}</div>
</div>
)
}.sd-emj { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-emj[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-emj :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-emj :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-emj .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-emj { width: min(100%, 320px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-emj__top { padding: 12px 12px 8px; }
.sd-emj__q { width: 100%; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; }
.sd-emj__q:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-emj__q::placeholder { color: var(--mute); }
.sd-emj__tabs { display: flex; gap: 2px; padding: 0 8px; border-bottom: 1px solid var(--line); }
.sd-emj__tab { flex: 1; height: 34px; font-size: 17px; border-radius: 8px 8px 0 0; position: relative; filter: grayscale(1); opacity: 0.55; transition: opacity 120ms, filter 120ms; }
.sd-emj__tab[aria-selected='true'] { filter: none; opacity: 1; }
.sd-emj__tab[aria-selected='true']::after { content: ''; position: absolute; left: 25%; right: 25%; bottom: -1px; height: 2px; border-radius: 2px; background: var(--fg); }
.sd-emj__body { height: 216px; overflow-y: auto; padding: 6px 8px; overscroll-behavior: contain; }
.sd-emj__h { margin: 6px 4px 4px; font-size: 11.5px; font-weight: 600; color: var(--mute); }
.sd-emj__grid { display: grid; grid-template-columns: repeat(var(--cols, 7), 1fr); }
.sd-emj__e { aspect-ratio: 1; display: grid; place-items: center; border-radius: 10px; font-size: 22px; line-height: 1; transition: transform 120ms cubic-bezier(0.3, 1.6, 0.4, 1), background-color 100ms; }
.sd-emj[data-size='l'] .sd-emj__e { font-size: 27px; }
.sd-emj__e:hover, .sd-emj__e[data-act] { background: var(--soft); }
.sd-emj__e:active { transform: scale(0.85); }
.sd-emj__none { padding: 40px 10px; text-align: center; color: var(--mute); font-size: 13px; }
.sd-emj__foot { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 14px; border-top: 1px solid var(--line); font-size: 13px; }
.sd-emj__foot span:first-child { font-size: 22px; }
.sd-emj__foot span:last-child { color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-emj *, .sd-emj *::before, .sd-emj *::after { animation: none !important; transition: none !important; } }Overview
Category tabs jump the grid; search filters by name and keyword across every category. Hovering or focusing an emoji shows its name in the footer.
Picks go to the front of Recents. Arrow keys move through the grid as a single tab stop.
When to use
- Reactions, message composers, status pickers.
When not to use
- A fixed set of five reactions: show them inline.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/emoji-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/emoji-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/emoji-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/emoji-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy emoji-picker.tsx and emoji-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { EmojiPicker } from '@/components/sundae/emoji-picker'
<EmojiPicker />Props
| Prop | Type | Default |
|---|---|---|
onPickCalled with the chosen emoji. | (emoji: string) => void | — |
columnsGrid columns. | number | 7 |
recentsStarting recents. | string[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The grid is a listbox-like group of buttons each labelled with the emoji name.
- One tab stop for the grid; arrows move, Enter picks.
- Search results count is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-emj; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move through the grid
- Enter
- Pick
- Type
- Search (from the search field)
FAQ
- Is Emoji Picker free?
- Yes. Emoji Picker is free for personal and commercial projects, with full source on this page.
- How do I install Emoji Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/emoji-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Emoji Picker support keyboard and screen readers?
- The grid is a listbox-like group of buttons each labelled with the emoji name. One tab stop for the grid; arrows move, Enter picks.
- Does Emoji Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Reaction Burst
Components · PlayfulIcon Picker
Components · RefinedMessage Thread
Components · RefinedKeyboard Shortcuts Sheet
Components · RefinedVoice Note
Components · RefinedGrid / List View Switcher
Components · Refined