EmojiPicker
FormSearchable Unicode reactions with categories, skin tones, and an optional popover trigger.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { useState } from "react";
import { EmojiPicker } from "@/components/ui/emoji-picker";
export default function EmojiPickerExample() {
const [reaction, setReaction] = useState("🎉");
return (
<div className="w-full max-w-md space-y-5">
<div>
<p className="text-xs uppercase tracking-widest text-primary">
Say a little more
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
The right reaction.
</h3>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm">
The new workspace is ready. Thanks for making it happen!
</p>
<p
role="status"
className="mt-3 inline-flex rounded-full border bg-primary/5 px-3 py-1 text-sm"
>
Your reaction: {reaction}
</p>
</div>
<EmojiPicker label="React to the update" onEmojiSelect={setReaction} />
<p className="text-xs text-muted-foreground">
Search by name, switch categories, or choose a skin tone for supported
gestures. The Unicode collection is editable.
</p>
</div>
);
}Usage
Use the inline EmojiPicker for reaction surfaces or EmojiPickerButton for a compact composer action. onEmojiSelect returns the selected Unicode string, including an applicable skin-tone modifier. The default collection contains 36 curated entries; it is deliberately a small editable dataset, not the complete Unicode emoji standard. Supply your own named/localized entries for broader coverage, with skinTone true only for supported base gestures. The picker does not mutate input text or send messages: the caller owns insertion, caret restoration, and submission. The Chat page demonstrates inserting into the current selection.
import { EmojiPicker } from "@/components/ui/emoji-picker";Shipped as editable source in src/components/ui/emoji-picker.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/emoji-picker.tsx
"use client";
import { useId, useRef, useState, type RefObject } from "react";
import { Smile } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@dashboardpack/core/components/ui/popover";
import { cn } from "@dashboardpack/core/lib/utils";
import { useDocumentDirection } from "@/lib/use-document-direction";
export interface EmojiEntry {
emoji: string;
name: string;
category: string;
keywords?: string;
skinTone?: boolean;
}
// A small editable Unicode collection; applications can supply a larger, localized dataset.
export const defaultEmojis: EmojiEntry[] = [
["😀", "Grinning face", "Faces", "happy smile"],
["😊", "Smiling face", "Faces", "happy blush"],
["😂", "Tears of joy", "Faces", "laugh funny"],
["🥹", "Holding back tears", "Faces", "thankful"],
["😍", "Heart eyes", "Faces", "love"],
["🤔", "Thinking face", "Faces", "question"],
["😎", "Sunglasses", "Faces", "cool"],
["🥳", "Party face", "Faces", "celebrate"],
["😴", "Sleeping face", "Faces", "tired"],
["😢", "Crying face", "Faces", "sad"],
["🙃", "Upside-down face", "Faces", "ironic"],
["🤩", "Star eyes", "Faces", "excited"],
["👍", "Thumbs up", "Gestures", "yes agree", true],
["👎", "Thumbs down", "Gestures", "no disagree", true],
["👋", "Waving hand", "Gestures", "hello goodbye", true],
["👏", "Clapping hands", "Gestures", "well done", true],
["🙌", "Raised hands", "Gestures", "hooray", true],
["🙏", "Folded hands", "Gestures", "thanks please", true],
["💪", "Flexed biceps", "Gestures", "strong", true],
["🤞", "Crossed fingers", "Gestures", "luck", true],
["❤️", "Red heart", "Symbols", "love"],
["💚", "Green heart", "Symbols", "love"],
["✨", "Sparkles", "Symbols", "new shine"],
["✅", "Check mark", "Symbols", "done approved"],
["⚠️", "Warning", "Symbols", "attention"],
["💡", "Light bulb", "Symbols", "idea"],
["🔥", "Fire", "Symbols", "hot"],
["🎉", "Party popper", "Symbols", "celebrate"],
["🚀", "Rocket", "Work", "launch ship"],
["🎯", "Target", "Work", "goal"],
["📅", "Calendar", "Work", "date"],
["📌", "Pin", "Work", "save"],
["💻", "Laptop", "Work", "computer"],
["📊", "Chart", "Work", "analytics"],
["☕", "Coffee", "Work", "break"],
["🌱", "Seedling", "Work", "grow"],
].map(([emoji, name, category, keywords, skinTone]) => ({
emoji: String(emoji),
name: String(name),
category: String(category),
keywords: String(keywords),
skinTone: Boolean(skinTone),
}));
const tones = ["", "🏻", "🏼", "🏽", "🏾", "🏿"];
const toneNames = [
"Default",
"Light",
"Medium-light",
"Medium",
"Medium-dark",
"Dark",
];
export function emojiWithTone(entry: EmojiEntry, tone: number) {
return (
entry.emoji +
(entry.skinTone
? tones[Math.min(5, Math.max(0, Math.trunc(tone) || 0))]
: "")
);
}
export interface EmojiPickerProps {
label?: string;
onEmojiSelect: (emoji: string) => void;
entries?: EmojiEntry[];
disabled?: boolean;
className?: string;
}
export function EmojiPicker({
label = "Choose an emoji",
onEmojiSelect,
entries = defaultEmojis,
disabled,
className,
}: EmojiPickerProps) {
const id = useId();
const grid = useRef<HTMLDivElement>(null);
const direction = useDocumentDirection();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("All");
const [tone, setTone] = useState(0);
const categories = [
"All",
...new Set(entries.map((entry) => entry.category)),
];
const results = entries.filter(
(entry) =>
(category === "All" || category === entry.category) &&
`${entry.name} ${entry.keywords ?? ""} ${entry.emoji}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
return (
<section
aria-label={label}
className={cn(
"w-full min-w-0 space-y-3 rounded-xl border bg-card p-3",
className,
)}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{label}</h3>
<span className="text-xs text-muted-foreground">Unicode</span>
</div>
<label htmlFor={`${id}-search`} className="sr-only">
Search emojis
</label>
<Input
id={`${id}-search`}
placeholder="Search a feeling or an idea"
value={query}
disabled={disabled}
onChange={(event) => setQuery(event.target.value)}
/>
<div
aria-label="Emoji categories"
role="group"
className="flex flex-wrap gap-1"
>
{categories.map((item) => (
<button
type="button"
key={item}
aria-pressed={category === item}
disabled={disabled}
onClick={() => setCategory(item)}
className={cn(
"rounded-md px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
category === item
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{item}
</button>
))}
</div>
<div
ref={grid}
role="group"
aria-label="Emoji results"
className="grid max-h-48 grid-cols-8 gap-1 overflow-auto"
onKeyDown={(event) => {
const buttons = [
...(grid.current?.querySelectorAll<HTMLButtonElement>("button") ??
[]),
];
const current = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
if (current < 0) return;
let next = current;
if (event.key === "ArrowRight") next += direction === "rtl" ? -1 : 1;
else if (event.key === "ArrowLeft")
next += direction === "rtl" ? 1 : -1;
else if (event.key === "ArrowDown") next += 8;
else if (event.key === "ArrowUp") next -= 8;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = buttons.length - 1;
else return;
event.preventDefault();
buttons[Math.max(0, Math.min(buttons.length - 1, next))]?.focus();
}}
>
{results.map((entry, index) => (
<button
key={`${entry.name}-${entry.emoji}`}
type="button"
disabled={disabled}
tabIndex={index === 0 ? 0 : -1}
aria-label={
entry.name +
(entry.skinTone && tone ? `, ${toneNames[tone]} skin tone` : "")
}
title={entry.name}
className="flex aspect-square items-center justify-center rounded-md text-2xl hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
onClick={() => onEmojiSelect(emojiWithTone(entry, tone))}
>
<span aria-hidden>{emojiWithTone(entry, tone)}</span>
</button>
))}
</div>
{!results.length && (
<p className="py-3 text-center text-sm text-muted-foreground">
No emojis found. Try another word.
</p>
)}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<p role="status" className="text-xs text-muted-foreground">
{results.length} emojis · Use arrow keys
</p>
<label className="flex items-center gap-2 text-xs">
Skin tone
<select
className="min-w-0 rounded-md border bg-background px-1 py-1"
value={tone}
disabled={disabled}
onChange={(event) => setTone(Number(event.target.value))}
>
{toneNames.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</select>
</label>
</div>
</section>
);
}
export function EmojiPickerButton({
onEmojiSelect,
disabled,
returnFocusRef,
}: Pick<EmojiPickerProps, "onEmojiSelect" | "disabled"> & {
returnFocusRef?: RefObject<HTMLElement | null>;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label="Insert emoji"
>
<Smile className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
onCloseAutoFocus={(event) => {
if (returnFocusRef?.current) {
event.preventDefault();
returnFocusRef.current.focus();
}
}}
align="end"
className="w-80 max-w-[calc(100vw-2rem)] border-0 bg-transparent p-0 shadow-none"
>
<EmojiPicker
onEmojiSelect={(emoji) => {
setOpen(false);
onEmojiSelect(emoji);
}}
/>
</PopoverContent>
</Popover>
);
}Required helper: src/lib/use-document-direction.ts
Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.
"use client";
import { useSyncExternalStore } from "react";
function subscribe(notify: () => void) {
const observer = new MutationObserver(notify);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
});
return () => observer.disconnect();
}
function snapshot(): "ltr" | "rtl" {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}
/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| onEmojiSelect | (emoji: string) => voidDefault: required | Receive a Unicode string without performing any sending or persistence. |
| entries | EmojiEntry[]Default: 36 curated entries | Editable emoji, name, category, optional keywords, and skinTone support. |
| label | stringDefault: Choose an emoji | Inline section heading and accessible name. |
| disabled / className | boolean / stringDefault: false / — | Disable interactions or extend inline layout. |
| EmojiPickerButton | componentDefault: — | Compact popover wrapper accepting onEmojiSelect, disabled, and optional returnFocusRef for composer focus restoration. |