Custom Shadcn Emoji Picker for React and Tailwind CSS. A shadcn emoji picker with search, shortcodes, skin tones, frequently used and custom emoji, a category bar, a virtualized grid, 28 data languages and form fields.
"use client"import { useState } from "react"import { EmojiPicker, EmojiPickerPanel,} from "@/components/reui/emoji-picker"
import { Card } from "@/components/ui/card"
export function Pattern() {
// Controlled, as an inline picker usually feeds a form. Nothing echoes it:
// the footer preview falls back to it and the panel announces each pick.
const [value, setValue] = useState<string | null>(null)
return (
// Card has no flush size, so `py-0` keeps its padding off the panel's own
// insets; `w-fit` hugs the panel when the parent is not a flex row.
<Card className="w-fit py-0">
<EmojiPicker value={value} onValueChange={setValue}>
<EmojiPickerPanel />
</EmojiPicker>
</Card>
)
}
The workplace-safe set, the localized picker and the other examples are on
the Emoji Picker page.
Chat composer
"use client"import { useEffect, useRef, useState } from "react"import { EmojiPicker, EmojiPickerContent, EmojiPickerTrigger,
} from "@/components/reui/emoji-picker"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/components/ui/avatar"
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Message,
MessageAvatar,
MessageContent,
MessageHeader,
} from "@/components/ui/message"
import { Separator } from "@/components/ui/separator"
import { SendIcon } from 'lucide-react'
type Author = { name: string; initials: string; avatar: string }
type ChatGroup = {
id: string
/* No author means the group is yours: end-aligned, primary, no avatar. */
author?: Author
time: string
lines: string[]
}
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?w=96&h=96&dpr=2&q=80`
const AMARA: Author = {
name: "Amara Okafor",
initials: "AO",
avatar: photo("1519699047748-de8e457a634e"),
}
const LUCAS: Author = {
name: "Lucas Martin",
initials: "LM",
avatar: photo("1527980965255-d3b416303d12"),
}
const PRIYA: Author = {
name: "Priya Shah",
initials: "PS",
avatar: photo("1584308972272-9e4e7685e80f"),
}
const MIRA: Author = {
name: "Mira Stone",
initials: "MS",
avatar: photo("1494790108377-be9c29b29330"),
}
const MEMBERS = [AMARA, LUCAS, PRIYA, MIRA]
const THREAD: ChatGroup[] = [
{
id: "g0",
author: PRIYA,
time: "9:05 AM",
lines: ["Morning! Slides are in the shared drive."],
},
{
id: "g1",
author: AMARA,
time: "9:12 AM",
lines: ["Launch review moved to 4 PM.", "Can everyone still make it?"],
},
{
id: "g2",
author: LUCAS,
time: "9:14 AM",
lines: ["Works for me 👍"],
},
{
id: "g3",
time: "9:15 AM",
lines: ["I'm in. Demo build is ready."],
},
{
id: "g4",
author: PRIYA,
time: "9:21 AM",
lines: ["Same. Big room is booked."],
},
]
export function Pattern() {
const [groups, setGroups] = useState(THREAD)
const [text, setText] = useState("")
const logRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const empty = !text.trim()
/* Keyed on the array, not its length: a follow-up joins your last group
and leaves the length unchanged. */
useEffect(() => {
const log = logRef.current
if (log) log.scrollTop = log.scrollHeight
}, [groups])
/* A blurred input keeps its selection, so the emoji replaces it and the
caret lands after it. Set here rather than after focus returns, because
a Shift+click pick keeps the picker open. */
const insert = (emoji: string) => {
const field = inputRef.current
if (!field) return
const { value } = field
const start = field.selectionStart ?? value.length
const end = field.selectionEnd ?? start
const next = value.slice(0, start) + emoji + value.slice(end)
const caret = start + emoji.length
field.value = next
field.setSelectionRange(caret, caret)
setText(next)
}
/* A fixed label, not a clock read, so the server render and the first
client render agree. */
const send = () => {
if (empty) return
const line = text.trim()
const last = groups.at(-1)
setGroups(
last && !last.author
? [...groups.slice(0, -1), { ...last, lines: [...last.lines, line] }]
: [
...groups,
{ id: `g${groups.length + 1}`, time: "Just now", lines: [line] },
]
)
setText("")
inputRef.current?.focus()
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>#launch-crew</CardTitle>
<CardDescription>4 members</CardDescription>
<CardAction>
{/* Decorative: the description already gives the count, and four
names read out before the thread would only be noise. */}
<AvatarGroup aria-hidden="true">
{MEMBERS.map((member) => (
<Avatar key={member.name} size="sm">
<AvatarImage src={member.avatar} alt="" />
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
</CardAction>
</CardHeader>
{/* One wrapper, so no Card gap falls between the rule and the
scroller: once the thread outgrows its cap, older messages scroll
under the rule itself. */}
<div>
<Separator />
{/* The seeded thread fits under the cap in every style, so it opens
whole; sends grow it until it scrolls. A log announces each
message as it lands, and it is focusable so Safari keyboard users
can scroll back through the history. */}
<CardContent
ref={logRef}
role="log"
aria-label="Messages in #launch-crew"
tabIndex={0}
className="max-h-160 overflow-y-auto"
>
<div className="flex flex-col gap-6 pt-4">
{groups.map((group) => (
<Message key={group.id} align={group.author ? "start" : "end"}>
{group.author && (
/* Top-aligned beside the name line, so every group reads
the same way: who, when, then what. The header names the
sender, so the photo stays silent. */
<MessageAvatar aria-hidden="true" className="self-start">
<Avatar>
<AvatarImage src={group.author.avatar} alt="" />
<AvatarFallback>{group.author.initials}</AvatarFallback>
</Avatar>
</MessageAvatar>
)}
<MessageContent>
<MessageHeader>
<span className="flex items-center gap-1.5">
{group.author ? (
<>
<span className="text-foreground">
{group.author.name}
</span>
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
</>
) : (
/* Your own groups show only the time, as chat apps
do. The hidden word keeps the log from announcing
a bare time. */
<span className="sr-only">You</span>
)}
<span className="font-normal">{group.time}</span>
</span>
</MessageHeader>
{/* An end row shrinks to fit, so the bubbles' 80% cap would
resolve against the group and wrap a short line. */}
<BubbleGroup className="w-full">
{group.lines.map((line, index) => (
<Bubble
key={index}
variant={group.author ? "muted" : "default"}
>
<BubbleContent>{line}</BubbleContent>
</Bubble>
))}
</BubbleGroup>
</MessageContent>
</Message>
))}
</div>
</CardContent>
</div>
<CardContent>
<InputGroup>
<InputGroupInput
ref={inputRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
/* Enter that confirms an IME composition is not a send. */
if (event.key === "Enter" && !event.nativeEvent.isComposing)
send()
}}
placeholder="Message #launch-crew"
aria-label="Message #launch-crew"
/>
<InputGroupAddon align="inline-end">
{/* Controlled to null so the trigger keeps its smiley: this
button inserts, it does not hold a value. */}
<EmojiPicker
value={null}
aria-label="Add emoji"
onEmojiSelect={({ value }) => insert(value)}
>
<EmojiPickerTrigger variant="ghost" size="icon-sm" />
{/* Above the field so the draft stays in view; it flips below
when the page leaves no room. */}
<EmojiPickerContent
side="top"
align="end"
returnFocus={inputRef}
/>
</EmojiPicker>
{/* Never `disabled`: inside an InputGroup that greys out the
whole composer. An empty send is a no-op that says so, and
the button fills once there is a draft. */}
<Button
variant={empty ? "ghost" : "default"}
size="icon-sm"
aria-label="Send message"
aria-disabled={empty}
onClick={send}
>
<SendIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
</InputGroup>
</CardContent>
</Card>
)
}
Message reactions
"use client"import { useId, useRef, useState } from "react"import { DEFAULT_EMOJI_PICKER_I18N, EmojiPicker, EmojiPickerCategories,
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerHeader,
EmojiPickerList,
EmojiPickerPreview,
EmojiPickerSearch,
EmojiPickerSkinTone,
EmojiPickerTrigger,
EmojiPickerValue,
findEmoji,
getEmojiValue,
useEmojiPicker,
type EmojiPickerEmoji,
type EmojiPickerSkinTone as SkinTone,
} from "@/components/reui/emoji-picker"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Item,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { SmilePlusIcon } from 'lucide-react'
type Reaction = {
/** The toned value, so each skin tone gets a pill of its own. */
emoji: string
label: string
/** Everyone else who reacted, in the order they did. */
people: string[]
mine: boolean
}
const INITIAL_REACTIONS: Reaction[] = [
{
emoji: "🎉",
label: "party popper",
people: ["Amara Okafor", "Priya Shah"],
mine: true,
},
{
emoji: "🚀",
label: "rocket",
people: ["Daniel Kim", "Michael Rodriguez", "Priya Shah"],
mine: false,
},
{
emoji: "👍🏽",
label: "thumbs up, medium skin tone",
people: ["Michael Rodriguez", "Amara Okafor"],
mine: false,
},
{ emoji: "👀", label: "eyes", people: ["Daniel Kim"], mine: false },
]
/* Untoned on purpose: the row draws them in the tone picked in the footer,
so a toned 👍 joins the matching pill instead of starting a new one. */
const QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉", "🙏", "👀"]
const TONES = DEFAULT_EMOJI_PICKER_I18N.labels.skinTones
/* The tone goes into the name, or two pills of one emoji would announce
the same thing. */
const labelOf = (emoji: EmojiPickerEmoji, tone: SkinTone) =>
tone > 0 && emoji.skins
? `${emoji.label}, ${TONES[tone].toLowerCase()} skin tone`
: emoji.label
const countOf = (reaction: Reaction) =>
reaction.people.length + (reaction.mine ? 1 : 0)
/* "Amara Okafor, Priya Shah and you": you always come last. */
function namesOf(reaction: Reaction) {
const names = reaction.mine ? [...reaction.people, "you"] : reaction.people
if (names.length === 1) return reaction.mine ? "You" : names[0]
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`
}
/* A composed first row in the popover, so the common reactions take one
click. It goes through the picker's own `select`, so the skin tone, the
recent list and closing on pick behave exactly like a grid pick. Columns
one cell wide from the grid's gutter put each glyph over a grid column. */
function QuickReactions({ reactions }: { reactions: Reaction[] }) {
const { emojis, skinTone, select } = useEmojiPicker()
return (
<div
role="group"
aria-label="Quick reactions"
className="grid auto-cols-(--emoji-picker-cell) grid-flow-col justify-start justify-items-center px-(--emoji-picker-gutter) pt-(--emoji-picker-padding)"
>
{QUICK_REACTIONS.map((glyph) => {
/* Undefined until the data arrives, which the trigger starts
fetching on hover, so the row is rarely disabled for long. */
const emoji = findEmoji(emojis, glyph)
const value = emoji ? getEmojiValue(emoji, skinTone) : glyph
const mine = reactions.some(
(reaction) => reaction.mine && reaction.emoji === value
)
return (
/* Pressed reads as a quiet fill, not a solid primary tile. */
<Button
key={glyph}
variant={mine ? "secondary" : "ghost"}
size="icon-sm"
aria-pressed={mine}
aria-label={emoji ? labelOf(emoji, skinTone) : glyph}
disabled={!emoji}
onClick={() => emoji && select(emoji)}
>
<span
aria-hidden="true"
className="text-(length:--emoji-picker-emoji) leading-none"
>
<EmojiPickerValue value={value} />
</span>
</Button>
)
})}
</div>
)
}
export function Pattern() {
const id = useId()
const [reactions, setReactions] = useState(INITIAL_REACTIONS)
const [announcement, setAnnouncement] = useState("")
const triggerRef = useRef<HTMLButtonElement>(null)
/* One path for a pill, a quick reaction and a grid pick: reacting with
an emoji you already used takes yours back, and a pill nobody is left
on goes away. */
const toggle = (emoji: string, label: string) => {
const existing = reactions.find((reaction) => reaction.emoji === emoji)
const mine = !existing?.mine
const next = existing
? reactions.map((reaction) =>
reaction.emoji === emoji ? { ...reaction, mine } : reaction
)
: [...reactions, { emoji, label, people: [], mine }]
const kept = next.filter((reaction) => countOf(reaction) > 0)
setReactions(kept)
setAnnouncement(
mine ? `You reacted with ${label}` : `Removed your ${label} reaction`
)
/* The pressed pill unmounts with its last reaction, and focus would
fall to the page; the add button sits beside the row. */
if (kept.length < next.length) triggerRef.current?.focus()
}
return (
<Item variant="outline" className="max-w-md">
{/* Top-aligned to the author line; an Item centres its media unless
the row has an ItemDescription. */}
<ItemMedia className="self-start">
<Avatar>
<AvatarImage
src="https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80"
alt=""
/>
<AvatarFallback>LM</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>
Lucas Martin
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span className="text-muted-foreground font-normal">10:42 AM</span>
</ItemTitle>
<p className="text-pretty">
Dark mode is live for 10% of workspaces. If error rates hold, everyone
gets it on Thursday.
</p>
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1.5 pt-2">
{reactions.map((reaction) => {
const count = countOf(reaction)
/* Keyed by the emoji, not the index, so a description never
moves to a neighbour when a pill above it goes away. */
const descriptionId = `${id}-${reaction.emoji}`
return (
<Tooltip key={reaction.emoji}>
{/* Yours are solid, everyone else's muted. */}
<TooltipTrigger asChild>
<Button
variant={reaction.mine ? "default" : "secondary"}
size="sm"
aria-pressed={reaction.mine}
aria-label={`${reaction.label}, ${count} ${
count === 1 ? "reaction" : "reactions"
}`}
aria-describedby={descriptionId}
onClick={() => toggle(reaction.emoji, reaction.label)}
>
<span aria-hidden="true" className="text-sm leading-none">
{reaction.emoji}
</span>
<span aria-hidden="true" className="tabular-nums">
{count}
</span>
{/* Base UI keeps tooltip text from screen readers, so
the names reach them through this instead. */}
<span id={descriptionId} hidden>
{namesOf(reaction)} reacted with {reaction.label}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{namesOf(reaction)}</TooltipContent>
</Tooltip>
)
})}
{/* Controlled to null so the trigger keeps its icon instead of
showing the last pick as a value. */}
<EmojiPicker
value={null}
aria-label="Add reaction"
onEmojiSelect={({ value, emoji, skinTone }) =>
toggle(value, labelOf(emoji, skinTone))
}
>
<EmojiPickerTrigger
ref={triggerRef}
variant="secondary"
size="icon-sm"
title="Add reaction"
>
<SmilePlusIcon aria-hidden="true" />
</EmojiPickerTrigger>
{/* Offset past the card's bottom edge, so the panel reads as
its own surface instead of clipping the corner. */}
<EmojiPickerContent side="bottom" align="end" sideOffset={20}>
<QuickReactions reactions={reactions} />
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
</div>
</TooltipProvider>
<p role="status" className="sr-only">
{announcement}
</p>
</ItemContent>
</Item>
)
}
Page icon
"use client"import { useId, useLayoutEffect, useRef, useState } from "react"import { EmojiPicker, EmojiPickerCategories, EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerHeader,
EmojiPickerList,
EmojiPickerPreview,
EmojiPickerSearch,
EmojiPickerSkinTone,
EmojiPickerTrigger,
EmojiPickerValue,
useEmojiPicker,
} from "@/components/reui/emoji-picker"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { PopoverTitle } from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { FileTextIcon, SmilePlusIcon } from 'lucide-react'
const RANDOM_CATEGORIES = new Set<string>([
"nature",
"food",
"travel",
"activities",
"objects",
])
/* Rendered inside the picker for its loaded emoji and its select(), so
Random commits and closes the popover exactly like a click on a cell. */
function IconMenuTitle({ titleId }: { titleId: string }) {
const { emojis, value, select, clear, setOpen } = useEmojiPicker()
return (
<div className="flex items-center justify-between gap-2 px-(--emoji-picker-padding) pt-(--emoji-picker-padding)">
<PopoverTitle id={titleId}>Icon</PopoverTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
disabled={emojis.length === 0}
onClick={() => {
/* Objects, places, food and nature make page icons. Faces read
as a mood, symbols as a status ("Black square button") and
flags render as letters on Windows. */
const pool = emojis.filter((emoji) =>
RANDOM_CATEGORIES.has(emoji.category)
)
select(pool[Math.floor(Math.random() * pool.length)])
}}
>
Random
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!value}
onClick={() => {
/* Closing hands focus back to the trigger, the same button now
reading Add icon. */
clear()
setOpen(false)
}}
>
Remove
</Button>
</div>
</div>
)
}
export function Pattern() {
const titleId = useId()
/* Preselected: the page already has a stored icon, kept as the emoji
itself so it fits a plain text column. */
const [icon, setIcon] = useState<string | null>("🎯")
const slotRef = useRef<HTMLDivElement>(null)
/* Every style pads a ghost button differently, so the pull is read off the
button: it puts the glyph, not the box, on the title's edge. The trigger
stays one node across both states, and each swap resizes it. */
useLayoutEffect(() => {
const slot = slotRef.current
const button = slot?.firstElementChild
if (!slot || !button) return
const observer = new ResizeObserver(() => {
const mark = button.querySelector("svg, [data-slot=emoji-picker-value]")
if (!mark) return
const box = button.getBoundingClientRect()
const glyph = mark.getBoundingClientRect()
const inset =
getComputedStyle(slot).direction === "rtl"
? box.right - glyph.right
: glyph.left - box.left
slot.style.marginInlineStart = `${-inset}px`
})
observer.observe(button)
return () => observer.disconnect()
}, [])
return (
<EmojiPicker value={icon} onValueChange={setIcon} aria-label="Page icon">
<Card className="w-full max-w-xl">
<CardHeader>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Workspace</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Projects</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{/* A fixed box, so the name stays put when the icon is
removed and a plain document glyph takes its place. The
emoji is set at the icon's 16px, not the crumb's text
size, so both read the same size in every style. */}
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="flex size-4 shrink-0 items-center justify-center text-base"
>
{icon ? (
<EmojiPickerValue />
) : (
<FileTextIcon className="size-4" />
)}
</span>
Q4 planning
</span>
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</CardHeader>
<Separator />
<CardContent>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
{/* One 72px row for both states, so the title never moves.
-ms-1.5 is the stored icon's pull, for the server render. */}
<div ref={slotRef} className="-ms-1.5 flex h-18 items-end">
{icon ? (
<EmojiPickerTrigger variant="ghost" className="size-18">
{/* The value draws at the font size around it, custom
image emoji included. */}
<span className="text-6xl">
<EmojiPickerValue />
</span>
</EmojiPickerTrigger>
) : (
/* Still the one trigger, so the popover keeps its anchor
and focus its target. The label matches the text. */
<EmojiPickerTrigger
variant="ghost"
size="sm"
aria-label="Add icon"
>
<SmilePlusIcon data-icon="inline-start" aria-hidden="true" />
Add icon
</EmojiPickerTrigger>
)}
</div>
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-semibold tracking-tight">
Q4 planning
</h2>
<p className="text-muted-foreground">
Goals, owners and launch dates for the quarter.
</p>
</div>
</div>
{/* The card's own type size, so the row matches the description
in every style. Each item leads with its dot and the row is
pulled out by dot plus gap, so the clip hides only the dot
that starts a line: wrapped or not, none ends or opens one. */}
<div className="overflow-x-clip">
<div className="text-muted-foreground -ms-3.5 flex flex-wrap items-center gap-x-2.5 gap-y-2">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<div className="flex items-center gap-2">
<Avatar size="sm">
<AvatarImage
src="https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80"
alt=""
/>
<AvatarFallback>AO</AvatarFallback>
</Avatar>
<span className="text-foreground font-medium">
Amara Okafor
</span>
</div>
</div>
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<Badge variant="secondary">On track</Badge>
</div>
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span>Edited 2 days ago</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Opens under the icon, like Notion, leaving it in view. The explicit
labelledby keeps the twins equal: only the Base UI PopoverTitle
names its popover on its own. */}
<EmojiPickerContent align="start" aria-labelledby={titleId}>
<IconMenuTitle titleId={titleId} />
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
)
}
Custom team emoji
"use client"import { useRef, useState } from "react"import { EMOJI_PICKER_CATEGORIES, EmojiPicker, EmojiPickerContent,
EmojiPickerTrigger,
type EmojiPickerCustomCategory,
type EmojiPickerCustomEmoji,
} from "@/components/reui/emoji-picker"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Item,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { Separator } from "@/components/ui/separator"
import { SendIcon } from 'lucide-react'
/* Inline SVG so the example ships no image files. In an app these are the
workspace's uploads, and any image URL works as `src`. One font size keeps
one cap height; 4-letter labels are fitted to 44 of the 64 units, so a
wider system font (Segoe UI, Roboto) cannot push them into the corners. */
const glyph = (text: string, color: string, size = 18) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="${color}"/><text x="32" y="${32 + size * 0.35}" font-family="system-ui,sans-serif" font-size="${size}" font-weight="800" letter-spacing="-0.5" fill="#fff" text-anchor="middle"${text.length >= 4 ? ' textLength="44" lengthAdjust="spacingAndGlyphs"' : ""}>${text}</text></svg>`
)}`
/* Emoji whose `category` matches a `customCategories` id get their own tab,
named by its label. The workspace logo replaces the default sparkle, the
way Discord marks each server's set. */
const WORKSPACE: EmojiPickerCustomCategory = {
id: "northwind",
label: "Northwind Labs",
icon: (
<img
src={glyph("N", "#4f46e5", 40)}
alt=""
width={16}
height={16}
className="size-4"
/>
),
}
/* The team's set leads, ahead of Frequently used, so it opens first however
much the picker has been used. */
const CATEGORIES = [WORKSPACE.id, "recent", ...EMOJI_PICKER_CATEGORIES]
/* Two full rows of the default 8 columns, so the set opens as a block of its
own above Smileys. The id doubles as the shortcode: `:lgtm:`. Where the
tile prints another word, that word is a second shortcode, so it ties the
native emoji of that name and the team's set, listed first, wins; a tag
ranks below a name match. Only the id is stored and rendered, so a typed
`:ship:` stays text and `:+1:` (a tag) stays the thumbs up. */
const TEAM_EMOJI: EmojiPickerCustomEmoji[] = [
{ id: "lgtm", label: "Looks good to me", src: glyph("LGTM", "#16a34a") },
{
id: "shipit",
label: "Ship it",
src: glyph("SHIP", "#7c3aed"),
shortcodes: ["shipit", "ship"],
},
{
id: "plusone",
label: "Plus one",
src: glyph("+1", "#2563eb"),
tags: ["+1", "agree", "yes"],
},
{ id: "wip", label: "Work in progress", src: glyph("WIP", "#d97706") },
{
id: "blocked",
label: "Blocked",
src: glyph("STOP", "#dc2626"),
shortcodes: ["blocked", "stop"],
},
{
id: "ty",
label: "Thank you",
src: glyph("TY", "#db2777"),
tags: ["thanks"],
},
{ id: "ack", label: "Acknowledged", src: glyph("ACK", "#475569") },
{
id: "hotfix",
label: "Hotfix",
src: glyph("FIX", "#ea580c"),
shortcodes: ["hotfix", "fix"],
},
{ id: "nit", label: "Nitpick", src: glyph("NIT", "#0891b2") },
{ id: "sync", label: "Let's sync", src: glyph("SYNC", "#65a30d") },
{
id: "revert",
label: "Revert",
src: glyph("UNDO", "#c026d3"),
shortcodes: ["revert", "undo"],
},
{
id: "live",
label: "Live in production",
src: glyph("LIVE", "#0284c7"),
tags: ["deployed"],
},
{ id: "brb", label: "Be right back", src: glyph("BRB", "#ca8a04") },
{
id: "looking",
label: "Taking a look",
src: glyph("LOOK", "#0d9488"),
shortcodes: ["looking", "look"],
tags: ["eyes"],
},
{ id: "help", label: "Need a hand", src: glyph("HELP", "#e11d48") },
{ id: "qa", label: "Ready for QA", src: glyph("QA", "#9333ea") },
].map((emoji) => ({ ...emoji, category: WORKSPACE.id }))
const BY_CODE = new Map(TEAM_EMOJI.map((emoji) => [`:${emoji.id}:`, emoji]))
/* Custom emoji travel as `:shortcode:` in the stored text and become images
on render, the way Slack and Discord keep them. `h-lh` fills the line
box each style sets (20px on nova, 16px on lyra) and the width follows
the square intrinsic size, so the line keeps its height. */
function renderText(text: string) {
return text.split(/(:[a-z0-9_+-]+:)/g).map((part, index) => {
const emoji = BY_CODE.get(part)
return emoji ? (
<img
key={index}
src={emoji.src}
alt={emoji.label}
title={part}
width={20}
height={20}
className="inline-block h-lh w-auto align-top"
/>
) : (
part
)
})
}
type Author = { name: string; initials: string; avatar: string }
type Post = { id: string; author: Author; time: string; text: string }
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?w=96&h=96&dpr=2&q=80`
const AMARA: Author = {
name: "Amara Okafor",
initials: "AO",
avatar: photo("1519699047748-de8e457a634e"),
}
const MICHAEL: Author = {
name: "Michael Rodriguez",
initials: "MR",
avatar: photo("1500648767791-00dcc994a43e"),
}
const DANIEL: Author = {
name: "Daniel Kim",
initials: "DK",
avatar: photo("1535713875002-d1d0cf377fde"),
}
const PRIYA: Author = {
name: "Priya Shah",
initials: "PS",
avatar: photo("1584308972272-9e4e7685e80f"),
}
const ME: Author = {
name: "Mira Stone",
initials: "MS",
avatar: photo("1494790108377-be9c29b29330"),
}
const ROOT: Post = {
id: "root",
author: AMARA,
time: "9:36 AM",
text: "Release candidate is on staging :shipit: and one flaky checkout spec is left :wip:",
}
const REPLIES: Post[] = [
{
id: "r1",
author: MICHAEL,
time: "9:48 AM",
text: "Pinned the payment mock :hotfix:",
},
{
id: "r2",
author: DANIEL,
time: "9:52 AM",
text: "Green on all three browsers :lgtm:",
},
{
id: "r3",
author: PRIYA,
time: "9:55 AM",
text: "Release notes are merged :ty: :plusone:",
},
]
function PostItem({
post,
variant,
}: {
post: Post
variant?: "default" | "muted"
}) {
return (
<Item variant={variant}>
{/* An empty alt: the name sits beside the photo, so a screen reader
would otherwise read it twice. Top-aligned by hand, since Item only
does that when it finds an ItemDescription. */}
<ItemMedia className="self-start">
<Avatar>
<AvatarImage src={post.author.avatar} alt="" />
<AvatarFallback>{post.author.initials}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>
{post.author.name}
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span className="text-muted-foreground font-normal">{post.time}</span>
</ItemTitle>
{/* Not ItemDescription, which clamps to two lines: a thread shows
every message whole, the one you just sent included. */}
<p className="text-pretty wrap-break-word">{renderText(post.text)}</p>
</ItemContent>
</Item>
)
}
export function Pattern() {
const [replies, setReplies] = useState(REPLIES)
const [text, setText] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const empty = !text.trim()
/* A blurred input keeps its selection, so the shortcode replaces it and
the caret lands after it. Padding it with spaces keeps it a token of its
own: in `at 10:30:ty:` the time would swallow the opening colon. */
const insert = (code: string) => {
const field = inputRef.current
if (!field) return
const { value, selectionStart, selectionEnd } = field
const start = selectionStart ?? value.length
const before = value.slice(0, start)
const after = value.slice(selectionEnd ?? start)
const chunk = `${before && !before.endsWith(" ") ? " " : ""}${code}${after.startsWith(" ") ? "" : " "}`
const caret = start + chunk.length + (after.startsWith(" ") ? 1 : 0)
const next = before + chunk + after
field.value = next
field.setSelectionRange(caret, caret)
setText(next)
}
/* A fixed label, not a clock read, so the server render and the first
client render agree. Focus goes back to the field for the next reply. */
const send = () => {
if (empty) return
setReplies([
...replies,
{
id: `r${replies.length + 1}`,
author: ME,
time: "Just now",
text: text.trim(),
},
])
setText("")
inputRef.current?.focus()
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Release 4.2</CardTitle>
<CardDescription>#release-train</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<PostItem post={ROOT} variant="muted" />
{/* The rows bring their own padding, so a tight gap here keeps
the label as far from the first reply as from the root. */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<span className="text-muted-foreground shrink-0 text-xs">
{replies.length} {replies.length === 1 ? "reply" : "replies"}
</span>
{/* Separator is shrink-0 at full width, so a flex-1 wrapper
gives it the room the label leaves. */}
<div className="flex-1">
<Separator />
</div>
</div>
{/* A log announces each reply as it lands, so a send needs no
separate status region. */}
<div role="log" aria-label="Replies" className="flex flex-col">
{replies.map((reply) => (
<PostItem key={reply.id} post={reply} />
))}
</div>
</div>
</div>
</CardContent>
<CardFooter>
<InputGroup>
<InputGroupInput
ref={inputRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
/* Enter that confirms an IME composition is not a send. */
if (event.key === "Enter" && !event.nativeEvent.isComposing)
send()
}}
placeholder="Reply"
aria-label="Reply to thread"
/>
<InputGroupAddon align="inline-end">
{/* Controlled to null so the trigger keeps its smiley: this
button inserts, it does not hold a value. Recent picks are
kept per workspace, since another workspace's `:shortcode:`
means nothing here. */}
<EmojiPicker
value={null}
aria-label="Add emoji"
customEmojis={TEAM_EMOJI}
customCategories={[WORKSPACE]}
categories={CATEGORIES}
recentStorageKey="northwind-emoji-recent"
onEmojiSelect={({ value }) => insert(value)}
>
<EmojiPickerTrigger variant="ghost" size="icon-sm" />
{/* Above the field so the draft stays in view; it flips below
when the thread leaves no room. */}
<EmojiPickerContent
side="top"
align="end"
returnFocus={inputRef}
/>
</EmojiPicker>
{/* Never `disabled`: inside an InputGroup that greys out the
whole field. An empty send is a no-op that says so, and the
button fills once there is a draft. It stays mounted rather
than appearing with the draft, so the emoji button never
jumps under the pointer. */}
<Button
variant={empty ? "ghost" : "default"}
size="icon-sm"
aria-label="Send reply"
aria-disabled={empty}
onClick={send}
>
<SendIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
</InputGroup>
</CardFooter>
</Card>
)
}
Shortcode suggestions
"use client"import { useEffect, useId, useRef, useState, type FormEvent } from "react"import { EmojiPicker, EmojiPickerContent, EmojiPickerTrigger,
getEmojiValue,
searchEmojis,
useEmojiPickerData,
type EmojiPickerEmoji,
} from "@/components/reui/emoji-picker"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { SendIcon } from 'lucide-react'
type Author = { name: string; initials: string; avatar: string }
type Comment = { id: string; author: Author; time: string; text: string }
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?w=96&h=96&dpr=2&q=80`
const PRIYA: Author = {
name: "Priya Shah",
initials: "PS",
avatar: photo("1584308972272-9e4e7685e80f"),
}
const LUCAS: Author = {
name: "Lucas Martin",
initials: "LM",
avatar: photo("1527980965255-d3b416303d12"),
}
const ME: Author = {
name: "Mira Stone",
initials: "MS",
avatar: photo("1494790108377-be9c29b29330"),
}
const THREAD: Comment[] = [
{
id: "c1",
author: PRIYA,
time: "10:12 AM",
text: "Moved the promo code field under the total and tightened the order summary. Ready for another look.",
},
{
id: "c2",
author: LUCAS,
time: "10:20 AM",
text: "Pay button stays above the fold on the smallest phones now.",
},
]
/* Ends mid-shortcode so the suggestions already show on load. */
const DRAFT = "So much cleaner now :fire"
/* `:` then two or more letters, right before the caret. */
const TOKEN = /(?:^|\s):([\p{L}\p{N}_+-]{2,})$/u
/* The rest of the word after the caret, replaced along with the token. */
const TAIL = /^[\p{L}\p{N}_+-]*/u
/* A finished `:shortcode:`, converted as soon as the closing colon lands. */
const FINISHED = /:([a-z0-9_+-]+):$/
const shortcodeOf = (emoji: EmojiPickerEmoji) =>
`:${emoji.shortcodes[0] ?? emoji.label.replace(/\s+/g, "_")}:`
const sentenceCase = (text: string) =>
text.charAt(0).toUpperCase() + text.slice(1)
export function Pattern() {
const id = useId()
/* The same shared data the picker loads, fetched once for both. */
const { data } = useEmojiPickerData()
const [comments, setComments] = useState(THREAD)
const [text, setText] = useState(DRAFT)
const [caret, setCaret] = useState(DRAFT.length)
const [highlight, setHighlight] = useState(0)
/* Escape hides the list for this exact token only; the next keystroke
makes a new token and the list comes back. */
const [dismissed, setDismissed] = useState<string | null>(null)
const fieldRef = useRef<HTMLInputElement>(null)
const empty = !text.trim()
/* The DOM caret starts at 0 while `caret` starts at the end; matching them
makes a click into the field resume where the draft stops. */
useEffect(() => {
fieldRef.current?.setSelectionRange(DRAFT.length, DRAFT.length)
}, [])
const token = TOKEN.exec(text.slice(0, caret))?.[1] ?? null
const suggestions =
token && data && token !== dismissed
? searchEmojis(data.emojis, token, { limit: 5 })
: []
const active = suggestions[Math.min(highlight, suggestions.length - 1)]
const open = suggestions.length > 0
const place = (next: string, position: number) => {
setText(next)
setCaret(position)
setHighlight(0)
/* Focus now, the caret after React writes the value, or the browser
parks it at the end of the text. A focus() deferred to that frame
would pull focus back from wherever a fast Tab already took it. */
fieldRef.current?.focus()
requestAnimationFrame(() =>
fieldRef.current?.setSelectionRange(position, position)
)
}
const accept = (emoji: EmojiPickerEmoji) => {
if (!token) return
const from = caret - token.length - 1
const to = caret + (TAIL.exec(text.slice(caret))?.[0].length ?? 0)
const rest = text.slice(to)
const value = getEmojiValue(emoji)
/* Reuse a space that already follows the word instead of doubling it;
either way the caret lands after the space, ready for the next word. */
const insert = /^\s/.test(rest) ? value : `${value} `
place(text.slice(0, from) + insert + rest, from + value.length + 1)
}
/* A pick from the picker lands at the caret. The field is blurred while
the popover is open, so this writes the value and the selection by
hand; returnFocus brings focus back with the caret already placed. */
const insert = (value: string) => {
const field = fieldRef.current
if (!field) return
const start = field.selectionStart ?? text.length
const end = field.selectionEnd ?? start
const next = text.slice(0, start) + value + text.slice(end)
field.value = next
field.setSelectionRange(start + value.length, start + value.length)
setText(next)
setCaret(start + value.length)
}
const post = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault()
/* An empty submit sends the user back to the field. */
if (empty) {
fieldRef.current?.focus()
return
}
setComments([
...comments,
{
id: `c${comments.length + 1}`,
author: ME,
time: "Just now",
text: text.trim(),
},
])
setDismissed(null)
place("", 0)
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Mobile checkout</CardTitle>
<CardDescription>Design review</CardDescription>
</CardHeader>
<CardContent>
{/* Plain rows, not Items: an Item pads its media in from the edge
the title and the composer share. A log announces each comment
as it lands, so a post needs no separate status region. */}
<div role="log" aria-label="Comments" className="flex flex-col gap-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{/* An empty alt: the name sits beside the photo. */}
<Avatar>
<AvatarImage src={comment.author.avatar} alt="" />
<AvatarFallback>{comment.author.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2 font-medium">
{comment.author.name}
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span className="text-muted-foreground font-normal">
{comment.time}
</span>
</div>
<p className="text-pretty wrap-break-word">{comment.text}</p>
</div>
</div>
))}
</div>
</CardContent>
<CardFooter>
<form onSubmit={post} className="flex w-full flex-col gap-2">
<p role="status" className="sr-only">
{open
? `${suggestions.length} emoji suggestion${suggestions.length === 1 ? "" : "s"}`
: ""}
</p>
<InputGroup>
<InputGroupInput
ref={fieldRef}
value={text}
role="combobox"
aria-expanded={open}
aria-controls={open ? `${id}-list` : undefined}
aria-activedescendant={active ? `${id}-${active.id}` : undefined}
aria-autocomplete="list"
aria-label="Add a comment"
placeholder="Add a comment"
onChange={(event) => {
const value = event.target.value
const position = event.target.selectionStart ?? value.length
setText(value)
setCaret(position)
setHighlight(0)
setDismissed(null)
const finished = FINISHED.exec(value.slice(0, position))
const match =
finished &&
data?.emojis.find((emoji) =>
emoji.shortcodes.includes(finished[1])
)
if (finished && match) {
const head =
value.slice(0, position - finished[0].length) +
getEmojiValue(match)
place(head + value.slice(position), head.length)
}
}}
/* The selection end, so a select-all on focus keeps the
token under the caret. */
onSelect={(event) =>
setCaret(event.currentTarget.selectionEnd ?? 0)
}
/* A list left open after focus moves on reads as stuck.
Coming back to an unfinished token shows it again. */
onBlur={() => setDismissed(token)}
onFocus={() => setDismissed(null)}
onKeyDown={(event) => {
/* Enter also confirms an IME composition; that one belongs
to the input method. */
if (event.nativeEvent.isComposing) return
/* Cmd/Ctrl+Enter posts the draft as typed, list or not. */
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
post()
return
}
/* With no list open, Enter submits and Tab leaves. */
if (!active) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
const step = event.key === "ArrowDown" ? 1 : -1
setHighlight(
(suggestions.indexOf(active) + step + suggestions.length) %
suggestions.length
)
} else if (
event.key === "Enter" ||
(event.key === "Tab" && !event.shiftKey)
) {
event.preventDefault()
accept(active)
} else if (event.key === "Escape") {
event.preventDefault()
setDismissed(token)
}
}}
/>
<InputGroupAddon align="inline-end">
{/* Controlled to null so the trigger keeps its smiley: this
button inserts, it does not hold a value. Opening it hides
the suggestions, which would sit under the popover. */}
<EmojiPicker
value={null}
aria-label="Add emoji"
onOpenChange={(next) => next && setDismissed(token)}
onEmojiSelect={({ value }) => insert(value)}
>
<EmojiPickerTrigger variant="ghost" size="icon-sm" />
{/* Above the field so the thread stays in view; it flips
below when the page leaves no room. */}
<EmojiPickerContent
side="top"
align="end"
returnFocus={fieldRef}
/>
</EmojiPicker>
{/* Never `disabled`: inside an InputGroup that greys out the
whole field. The button fills once there is a draft. */}
<Button
type="submit"
variant={empty ? "ghost" : "default"}
size="icon-sm"
aria-label="Post comment"
aria-disabled={empty}
>
<SendIcon aria-hidden="true" />
</Button>
</InputGroupAddon>
</InputGroup>
{/* Under the field, so the line being typed never moves when it
opens. Rows are not focusable: the field keeps focus and
aria-activedescendant points at one. A press would blur the
field, closing the list before the click lands. */}
{open && (
<div
id={`${id}-list`}
role="listbox"
aria-label="Emoji suggestions"
className="flex flex-col"
onPointerDown={(event) => event.preventDefault()}
>
{suggestions.map((emoji, index) => (
<Item
key={emoji.id}
id={`${id}-${emoji.id}`}
role="option"
size="xs"
/* Outline, not muted: bg-muted/50 all but vanishes on a
tinted footer, a border reads in every style and theme. */
variant={emoji === active ? "outline" : "default"}
aria-selected={emoji === active}
onPointerMove={() => setHighlight(index)}
onClick={() => accept(emoji)}
>
<ItemMedia aria-hidden="true">
<span className="text-xl leading-none">{emoji.emoji}</span>
</ItemMedia>
<ItemContent className="min-w-0">
{/* One row on a shared baseline: the 12px shortcode
centred beside the 14px name sits visibly high.
Both ellipsize, so a long name never wraps the row
or pushes the emoji onto a line of its own; the
shortcode, which repeats the name, gives way first. */}
<div className="flex min-w-0 items-baseline justify-between gap-2">
<ItemTitle>
<span className="truncate">
{sentenceCase(emoji.label)}
</span>
</ItemTitle>
<ItemDescription className="shrink-3">
<span className="block truncate">
{shortcodeOf(emoji)}
</span>
</ItemDescription>
</div>
</ItemContent>
</Item>
))}
</div>
)}
</form>
</CardFooter>
</Card>
)
}
API Reference
Emoji Picker is composable. EmojiPicker holds the data, the value, the
search, the skin tone and the recent list, and renders no wrapper element.
EmojiPickerTrigger opens EmojiPickerContent, and EmojiPickerPanel renders
the same parts in place. The grid is one listbox of category groups that only
renders the rows in view, so 50 reaction pickers on a page or a 20,000 custom
emoji library stay cheap. It composes the shadcn Popover, Button and
InputGroup, and installs no npm dependency besides cn.
Data
The emoji, their names and keywords come from
emojibase (MIT), fetched once per page from a pinned
release on jsDelivr and shared by every picker:
https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0, exported as
EMOJI_PICKER_DATA_URL. Only JSON is fetched, and it renders as text. The
browser caches the pinned files, so a return visit makes no request.
Language.locale loads names, keywords and category labels in one of
28 languages (en, de, fr, ja, zh, pt, es and more), and search
works in that language.
Shortcodes.shortcodes adds :thumbsup: style codes to search and the
preview: "github" (default), "iamcal" (Slack), "emojibase" or "cldr",
or several. GitHub and Slack codes are English only. false skips the
request.
Unsupported emoji.emojiVersion="auto" (default) draws one emoji of
each Unicode version on a canvas and hides the versions the device would show
as empty boxes, and country flags where the system font has none (Windows).
Pass a number to fix the version instead.
Self-host the data for a strict Content Security Policy or an offline app:
copy emojibase-data to your own origin and pass dataUrl, or bundle it and
pass data to skip the fetch entirely. Without either, allow the pinned path
in connect-src:
import { normalizeEmojibaseData } from "@/components/reui/emoji-picker"import raw from "emojibase-data/en/data.json"import messages from "emojibase-data/en/messages.json"import github from "emojibase-data/en/shortcodes/github.json"const DATA = normalizeEmojibaseData(raw, { messages, shortcodes: [github] })<EmojiPicker data={DATA} />
Value
A pick hands onEmojiSelect the value to insert or store: the native emoji
with the skin tone applied ("👍🏽"), or :shortcode: for a custom emoji
(":partyparrot:"). Values compare with or without the emoji variation
selector and in any tone, so a stored "👍" still marks its cell.
value, defaultValue and onValueChange make it a field: the trigger shows
the value, the grid marks it, and name submits it. multiple turns the value
into a list that picks toggle in and out of, with max as a cap.
type EmojiPickerSelection = { value: string emoji: EmojiPickerEmoji skinTone: EmojiPickerSkinTone}type EmojiPickerEmoji = { id: string // "1F44D", or a custom emoji's id emoji: string // "👍️", empty for a custom emoji label: string // "thumbs up", in the data's language tags: string[] shortcodes: string[] category: string version: number skins?: string[] // light to dark src?: string // a custom emoji's image}
i18n
Every string the picker renders comes from i18n, merged over the English
defaults per section, so one label is enough. Category names fall back to the
data's own language when locale is not English.
Label
Default
Used for
trigger
"Add emoji"
The trigger's name while empty.
panel
"Emoji picker"
Names the popover and an inline panel.
search, clearSearch
"Search emoji", "Clear search"
The search placeholder and name, and its clear icon.
list, categories
"Emoji", "Emoji categories"
Name the grid and the tabs.
frequent, recent
"Frequently used", "Recently used"
The recent section, per recentMode.
searchResults, custom
"Search results", "Custom"
The results section, and custom emoji without one.
smileys to flags
"Smileys & emotion" to "Flags"
The 9 category names.
skinTone, skinTones
"Skin tone", 6 names
The tone control: default, then light to dark.
empty, emptyHint
"No emoji found", a hint
The empty search state.
loading, error, retry
"Loading emoji", ...
The loading and failed states.
preview
"Pick an emoji"
The preview while nothing is highlighted.
Function
Type
Description
formatResultCount
(count: number) => string
Announced after a search.
formatSelected
(label: string) => string
Announced after a pick that keeps the picker open.
formatLabel
(label: string) => string
Display name from the lowercase data label.
EmojiPicker
The root. Without children it renders an EmojiPickerTrigger and an
EmojiPickerContent with the default layout, so <EmojiPicker /> alone is a
complete picker.
Prop
Type
Default
Description
onEmojiSelect
(selection: EmojiPickerSelection) => void
-
Fires on every pick with the toned value. The insert-into-text hook.
value
string | null, or string[] with multiple
-
Controlled value.
defaultValue
string | null, or string[]
-
Initial value when uncontrolled, and what a form reset restores.
onValueChange
(value) => void
-
Fires with the new value or list.
multiple
boolean
false
Picks toggle in and out of a list, and the popover stays open.
max
number
-
With multiple, the most values; further cells refuse a pick.
open, defaultOpen, onOpenChange
-
-
The popover state.
closeOnSelect
boolean
!multiple
Closes the popover after a pick. Shift+click keeps it open either way.
data
EmojiPickerData
-
Data already in hand. Skips the fetch.
locale
string
"en"
An emojibase locale.
dataUrl
string
EMOJI_PICKER_DATA_URL
Base URL of a self-hosted emojibase-data copy.
shortcodes
EmojiPickerShortcodePreset | ...[] | false
"github"
Shortcode sets searched and shown.
emojiVersion
number | "auto"
"auto"
Hides emoji newer than this Unicode Emoji version, or what the device cannot draw.
categories
string[]
all
Sections shown, in order: "recent", custom category ids and category keys.
Draws an emoji: an image set, a sprite or a brand font.
disabled, invalid
boolean
false
Disable the picker, or set aria-invalid on the trigger.
name, form, required, inputRef
-
-
A native form field. Multiple values submit comma-joined.
id
string
generated
Lands on the trigger.
aria-label, aria-labelledby
string
-
Name the trigger, the popover and an inline panel.
aria-describedby
string
-
Describes the trigger.
i18n
EmojiPickerI18nOverrides
English
Labels and formatting, merged over the defaults.
Recent emoji and the skin tone are stored under their keys and shared live by
every picker and tab on the origin. The server renders neither, so the markup
never depends on the visitor.
EmojiPickerTrigger
A shadcn Button that opens the popover. It shows the value, or a smiley icon,
and starts the data fetch on hover or focus, so the grid is ready on open.
Takes every Button prop; variant defaults to "outline" and size to
"icon".
EmojiPickerContent
The popover. It wraps its children in an EmojiPickerPanel, so it takes the
same parts; with none it renders the default layout. Focus moves to the search
on open (to the grid on touch screens, where the keyboard would cover it), Tab
cycles inside, and Escape closes it.
Prop
Type
Default
Description
returnFocus
RefObject<HTMLElement | null>
trigger
Where focus goes on close, such as a composer's textarea.
align
"start" | "center" | "end"
"start"
Popover alignment. side and the other popover props pass through.
EmojiPickerPanel
The picker surface. Put it straight inside EmojiPicker for an inline picker.
Without children it renders EmojiPickerHeader (the search),
EmojiPickerCategories, EmojiPickerList and EmojiPickerFooter (the preview
and the skin tone); with children, only those, in that order, so the tabs can
sit under the grid, the skin tone can move beside the search, or the footer
can go. --emoji-picker-surface colours the sticky headers: the card
inline (put an inline panel in a Card) and the popover in a popup.
--emoji-picker-font swaps the emoji font stack. The search, the tabs, the
section names, the first column of glyphs and the preview all start 8px in,
the panel's padding, so header, grid and footer line up. A row you compose
into the panel lines up the same way with px-(--emoji-picker-padding), as
the page icon example's title row does.
EmojiPickerHeader, EmojiPickerFooter
The rows above and below the grid, each a flex row at the panel's padding.
EmojiPickerFooter draws the divider and holds the preview and the skin tone
by default; put your own actions in either, such as a page icon's Random and
Remove.
EmojiPickerSearch, EmojiPickerSkinTone
EmojiPickerSearch is a default shadcn InputGroup with a search icon and a
clear button. Focus stays in it while the arrow keys move through the grid.
EmojiPickerSkinTone is a shadcn Button with a hand in the current tone that
opens the 6 tones in a shadcn DropdownMenu; it takes every Button prop. It
is ghost at icon-sm for the footer; beside the search, outline at icon
matches the search's height in every style. After a pick, focus returns to it.
EmojiPickerCategories
The shadcn Tabs in its line variant, one icon per category, sharing the
row evenly and underlining the current one on the divider. They jump the grid
and follow its scroll; arrow keys move between them without jumping, and Enter
or Space jumps. The keyboard then enters the grid at that category. A jump
during a search clears the search.
EmojiPickerList
The virtual grid, with its loading (shadcn Skeleton), error and empty
(shadcn Empty) states. Its height is --emoji-picker-list-height (7 rows); a
className height replaces it. The scrollbar is hidden: an end with rows
scrolled out of view fades instead, the top one under the sticky section
header, and carries data-overflow-start or data-overflow-end.
--emoji-picker-fade-size sets the depth, two thirds of a cell by default, and
0px turns it off. The grid is a tab stop of its own. The emoji the keyboard
is on takes the shadcn Button focus ring and data-keyboard, as does the
grid, whose fades pause meanwhile so they never cut the ring.
EmojiPickerPreview, EmojiPickerValue
EmojiPickerPreview shows the highlighted emoji large, with its name and first
shortcode on one line, or the value while nothing is highlighted. It fills the
space it is given, usually an EmojiPickerFooter, and the shortcode gives way
before the name does. EmojiPickerValue draws
an emoji value the way the grid does, custom images included.
useEmojiPicker
The picker's state and actions inside an EmojiPicker: data, status,
emojis (what the grid can show), query, setQuery, skinTone,
setSkinTone, activeEmoji, value, values, recent, select(emoji),
clear(), open and setOpen. The page icon example builds its Random and
Remove buttons from it.
useEmojiPickerData and helpers
useEmojiPickerData(options) loads the same shared data without a picker, for
a shortcode autocomplete or a reaction bar, and returns
{ data, status, error, retry }. The helpers are plain functions:
Helper
Returns
loadEmojiPickerData(options)
A promise of the data, fetched once per URL, locale and code set.
normalizeEmojibaseData(raw, options)
Picker data from emojibase files you bundle yourself.
searchEmojis(emojis, query, { limit })
Emoji ranked by name, shortcode and keyword.
getEmojiValue(emoji, skinTone)
The value a pick would store.
findEmoji(emojis, value), isEmojiValue
The emoji behind a stored value, in any tone.
Keyboard
Key
Action
Tab
Moves through the search, the category tabs, the grid and the skin tone.
ArrowDown
Enters the grid at the first emoji in view, then moves down a row.
ArrowUp
Moves up a row; from the top row, hands the arrows back to the search.
ArrowLeft/Right
Moves through the grid once it is entered, mirrored in right-to-left text.
PageUp/PageDown
Moves a screen of rows.
Enter
Picks the highlighted emoji. While searching, the best match.
Shift+Enter
Picks and keeps the popover open.
Home/End, Space
With focus in the grid: first, last, pick.
A letter
With focus in the grid: moves to the search and types it.
Escape
Closes the popover; inline, clears the search.
Accessibility
The search is a combobox over the grid's listbox, with the highlighted emoji
as its active descendant, so screen readers announce each emoji by name while
focus stays in the search. The grid itself, reached with Tab, keeps the same
active descendant. Category groups are labelled by their headers,
options carry their position in the category, the tabs are a tablist with
roving focus, and the skin tone is a menu button over a group of radio items.
Result counts and picks that
keep the picker open are announced through a polite live region.
Shadcn Emoji Picker Free Components
Browse 8 production-ready Shadcn Emoji Picker components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.