Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.7K
Sign inGet All-Access
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Embed
  • Agent Skills
  • llms.txt
  • RTL
  • Changelogv2.8.0
  • YouTube
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
  • WSWindsurf
  • CLCline
  • Gemini CLI
  • AMAmp
  • JBJetBrains Junie
Components
  • Alert
  • Autocomplete
  • Badge
  • Cascader
  • Code Block
  • Color PickerNew Color Picker docs
  • Data GridData Grid adds grouped and spanning headers, scrolls faster, and its scrollbar stops above a sticky footer
  • Date Selector
  • Emoji PickerNew Emoji Picker docs
  • Event Calendar
  • File TypeNew File Type docs
  • File Upload
  • Filters
  • Frame
  • Gantt
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Signature Pad
  • Sortable
  • StepperAdded 29 new Stepper examples, 44 in all
  • Time Picker
  • Timeline
  • Tree

Shadcn Emoji Picker

PreviousNext

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.

Base UIRadix UI
Radix UI
"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>
  )
}

Installation

pnpm dlx shadcn@latest add @reui/emoji-picker

Usage

import {
  EmojiPicker,
  EmojiPickerCategories,
  EmojiPickerContent,
  EmojiPickerFooter,
  EmojiPickerHeader,
  EmojiPickerList,
  EmojiPickerPreview,
  EmojiPickerSearch,
  EmojiPickerSkinTone,
  EmojiPickerTrigger,
} from "@/components/reui/emoji-picker"
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
  <EmojiPickerTrigger />
  <EmojiPickerContent>
    <EmojiPickerHeader>
      <EmojiPickerSearch />
    </EmojiPickerHeader>
    <EmojiPickerCategories />
    <EmojiPickerList />
    <EmojiPickerFooter>
      <EmojiPickerPreview />
      <EmojiPickerSkinTone />
    </EmojiPickerFooter>
  </EmojiPickerContent>
</EmojiPicker>

For AI Agents

Hand your agent this brief or the ReUI MCP, not a screenshot. Free to install.

When to use

  • Message composers, replies and notes that insert emoji at the caret of a text field
  • Reactions on posts and messages, added from a popover beside the reaction pills
  • A single stored emoji such as a page icon or a profile status, kept inline or behind a trigger

When not to use

  • A finished assistant conversation screen around a composer: use the AI Chat blocks
  • Rendering a conversation as bubbles with avatars, names and times: use the Message component
  • A hue chosen from swatches, a spectrum or a typed hex value: use the Color Picker component

Agent notes

  • Left unset, value keeps each pick as the picker's value and EmojiPickerTrigger draws it in place of its default smiley; insert-only pickers pass null
  • By default every picker on the origin shares recent emoji and skin tone through localStorage; scope them with recentStorageKey and skinToneStorageKey
Markdownhttps://reui.io/docs/components/radix/emoji-picker.md
MCPget_component("emoji-picker")
Installnpx shadcn@latest add @reui/emoji-picker
Full index for agents:llms.txtReUI MCP setup

Examples

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:

connect-src 'self' https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0/
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.

LabelDefaultUsed 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 namesThe tone control: default, then light to dark.
empty, emptyHint"No emoji found", a hintThe empty search state.
loading, error, retry"Loading emoji", ...The loading and failed states.
preview"Pick an emoji"The preview while nothing is highlighted.
FunctionTypeDescription
formatResultCount(count: number) => stringAnnounced after a search.
formatSelected(label: string) => stringAnnounced after a pick that keeps the picker open.
formatLabel(label: string) => stringDisplay 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.

PropTypeDefaultDescription
onEmojiSelect(selection: EmojiPickerSelection) => void-Fires on every pick with the toned value. The insert-into-text hook.
valuestring | null, or string[] with multiple-Controlled value.
defaultValuestring | null, or string[]-Initial value when uncontrolled, and what a form reset restores.
onValueChange(value) => void-Fires with the new value or list.
multiplebooleanfalsePicks toggle in and out of a list, and the popover stays open.
maxnumber-With multiple, the most values; further cells refuse a pick.
open, defaultOpen, onOpenChange--The popover state.
closeOnSelectboolean!multipleCloses the popover after a pick. Shift+click keeps it open either way.
dataEmojiPickerData-Data already in hand. Skips the fetch.
localestring"en"An emojibase locale.
dataUrlstringEMOJI_PICKER_DATA_URLBase URL of a self-hosted emojibase-data copy.
shortcodesEmojiPickerShortcodePreset | ...[] | false"github"Shortcode sets searched and shown.
emojiVersionnumber | "auto""auto"Hides emoji newer than this Unicode Emoji version, or what the device cannot draw.
categoriesstring[]allSections shown, in order: "recent", custom category ids and category keys.
filter(emoji: EmojiPickerEmoji) => boolean-Drops emoji. Pass a stable function.
customEmojisEmojiPickerCustomEmoji[]-Image emoji: { id, label, src, tags?, shortcodes?, category? }.
customCategoriesEmojiPickerCustomCategory[]-Tabs for custom emoji: { id, label, icon? }.
recentMode"frequent" | "recent""frequent"Orders the recent section by use count or by last use.
recentLimitnumber2 rowsHow many recent emoji show. 0 hides the section.
recent, defaultRecentEmojiPickerRecentEntry[]-A controlled or seeded recent list, for syncing it to an account.
onRecentChange(recent: EmojiPickerRecentEntry[]) => void-Fires after each pick with the updated list.
recentStorageKeystring | null"reui-emoji-picker-recent"localStorage key for the recent list. null keeps it in memory.
skinTone, defaultSkinToneEmojiPickerSkinTone0The tone, 0 (default) to 5 (dark).
onSkinToneChange(tone: EmojiPickerSkinTone) => void-Fires when the tone changes.
skinToneStorageKeystring | null"reui-emoji-picker-skin-tone"localStorage key for the tone. null keeps it in memory.
query, onQueryChangestring, (query: string) => void-Controlled search text.
size"sm" | "default" | "lg""default"16, 20 or 26px glyphs in 26, 30 or 38px cells.
columnsnumber | "auto"8Cells per row. "auto" fills the panel's width.
renderEmoji(emoji: EmojiPickerEmoji, value: string) => ReactNode-Draws an emoji: an image set, a sprite or a brand font.
disabled, invalidbooleanfalseDisable the picker, or set aria-invalid on the trigger.
name, form, required, inputRef--A native form field. Multiple values submit comma-joined.
idstringgeneratedLands on the trigger.
aria-label, aria-labelledbystring-Name the trigger, the popover and an inline panel.
aria-describedbystring-Describes the trigger.
i18nEmojiPickerI18nOverridesEnglishLabels 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.

PropTypeDefaultDescription
returnFocusRefObject<HTMLElement | null>triggerWhere 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:

HelperReturns
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), isEmojiValueThe emoji behind a stored value, in any tone.

Keyboard

KeyAction
TabMoves through the search, the category tabs, the grid and the skin tone.
ArrowDownEnters the grid at the first emoji in view, then moves down a row.
ArrowUpMoves up a row; from the top row, hands the arrows back to the search.
ArrowLeft/RightMoves through the grid once it is entered, mirrored in right-to-left text.
PageUp/PageDownMoves a screen of rows.
EnterPicks the highlighted emoji. While searching, the best match.
Shift+EnterPicks and keeps the popover open.
Home/End, SpaceWith focus in the grid: first, last, pick.
A letterWith focus in the grid: moves to the search and types it.
EscapeCloses 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.

Browse all 8 Shadcn Emoji Picker components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

Date SelectorEvent Calendar

On This Page

InstallationUsageFor AI AgentsExamplesChat composerMessage reactionsPage iconCustom team emojiShortcode suggestionsAPI ReferenceDataValuei18nEmojiPickerEmojiPickerTriggerEmojiPickerContentEmojiPickerPanelEmojiPickerHeader, EmojiPickerFooterEmojiPickerSearch, EmojiPickerSkinToneEmojiPickerCategoriesEmojiPickerListEmojiPickerPreview, EmojiPickerValueuseEmojiPickeruseEmojiPickerData and helpersKeyboardAccessibility

Application

  • App ShellAdded 4 new App Shell blocks
  • Auth
  • Card
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Flow
  • Form
  • Gantt
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Rich Text Editor
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Whiteboard
  • Wizard

Data Grid

  • Base
  • AdvancedNew Advanced category with 3 Data Grid blocks
  • Columns
  • Drag & Drop
  • Editing
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Charts

  • Area Chart
  • Bar Chart
  • Composed Chart
  • Line Chart
  • Pie Chart
  • Radial Chart
  • Sparkline
  • Status Tracker

AI & Agents

  • AI Chat
  • Agent Activity

Solutions

  • Dev Ops
  • AI Ops
  • CRM
  • Agents
  • Analytics
  • Billing
  • Bookings
  • Files
  • Inventory
  • Users

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist
  • Shop Hero

Marketing

  • Blog
  • Compare
  • Contact
  • CTA
  • FAQ
  • Hero
  • How It Works
  • Pricing

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Tailwind Plus Alternative
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.7K