{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "emoji-picker",
  "type": "registry:ui",
  "title": "Emoji Picker",
  "description": "Search-first emoji grid with arrow keys, skin tones and categories that follow the scroll.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/emoji-picker.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Clock, Search } from \"@/lib/icons\";\nimport { ease, spring, swap } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ───────────────────────── data ───────────────────────── */\n// A curated set of the emoji people actually reach for in product conversations:\n// reactions, status, work objects. One line per emoji: glyph | name | keywords.\n// A trailing \"|t\" marks emoji that take a skin tone.\n\nexport type EmojiCategoryKey = \"smileys\" | \"people\" | \"nature\" | \"food\" | \"activity\" | \"travel\" | \"objects\" | \"symbols\" | \"flags\";\n\nexport type EmojiEntry = { emoji: string; name: string; keywords: string; tone: boolean; category: EmojiCategoryKey };\n\nconst raw: Record<EmojiCategoryKey, string> = {\n  smileys: `\n😀|grinning face|smile happy\n😃|grinning face with big eyes|smile happy joy\n😄|grinning face with smiling eyes|smile happy laugh\n😁|beaming face|grin smile teeth\n😆|grinning squinting face|laugh lol\n😅|grinning face with sweat|relief nervous phew\n🤣|rolling on the floor laughing|rofl lol lmao\n😂|face with tears of joy|lol laugh crying\n🙂|slightly smiling face|smile fine\n🙃|upside down face|silly sarcasm\n😉|winking face|wink flirt\n😊|smiling face with smiling eyes|blush happy\n😇|smiling face with halo|angel innocent\n🥰|smiling face with hearts|love adore\n😍|smiling face with heart eyes|love crush\n🤩|star struck|wow amazing excited\n😘|face blowing a kiss|kiss love\n😋|face savoring food|yum delicious\n😛|face with tongue|tongue playful\n😜|winking face with tongue|crazy joke\n🤪|zany face|goofy wild\n🤑|money mouth face|rich money\n🤗|hugging face|hug thanks\n🤭|face with hand over mouth|oops giggle\n🫢|face with open eyes and hand over mouth|shock gasp\n🤫|shushing face|quiet secret\n🤔|thinking face|hmm think consider\n🫡|saluting face|salute respect ok\n🤐|zipper mouth face|secret quiet\n🤨|face with raised eyebrow|skeptical suspicious\n😐|neutral face|meh blank\n😑|expressionless face|blank unamused\n😶|face without mouth|speechless silent\n🫥|dotted line face|invisible hidden\n😏|smirking face|smirk smug\n😒|unamused face|meh annoyed\n🙄|face with rolling eyes|eyeroll whatever\n😬|grimacing face|awkward yikes\n😮‍💨|face exhaling|sigh relief tired\n😌|relieved face|calm content\n😔|pensive face|sad thoughtful\n😪|sleepy face|tired\n😴|sleeping face|sleep zzz tired\n😷|face with medical mask|sick mask ill\n🤒|face with thermometer|sick fever\n🤢|nauseated face|sick gross\n🥵|hot face|heat sweating\n🥶|cold face|freezing\n🥴|woozy face|dizzy tipsy\n😵‍💫|face with spiral eyes|dizzy confused\n🤯|exploding head|mind blown shocked\n🤠|cowboy hat face|yeehaw\n🥳|partying face|party celebrate birthday\n🥸|disguised face|incognito\n😎|smiling face with sunglasses|cool\n🤓|nerd face|geek smart\n🧐|face with monocle|inspect curious\n😕|confused face|unsure\n🫤|face with diagonal mouth|skeptical unsure\n😟|worried face|concern nervous\n🙁|slightly frowning face|sad\n😮|face with open mouth|wow surprise\n😲|astonished face|shock amazed\n😳|flushed face|embarrassed blush\n🥺|pleading face|please puppy eyes\n🥹|face holding back tears|touched grateful\n😨|fearful face|scared\n😰|anxious face with sweat|nervous stress\n😢|crying face|sad tear\n😭|loudly crying face|sob sad\n😱|face screaming in fear|scream horror\n😩|weary face|tired frustrated\n😫|tired face|exhausted\n🥱|yawning face|bored tired\n😤|face with steam from nose|triumph frustrated\n😡|enraged face|angry mad\n🤬|face with symbols on mouth|swear angry\n😈|smiling face with horns|devil mischief\n💀|skull|dead lol\n💩|pile of poo|poop\n🤡|clown face|clown\n👻|ghost|boo halloween\n👽|alien|ufo extraterrestrial\n🤖|robot|bot ai automation`,\n  people: `\n👋|waving hand|hello hi bye wave|t\n🤚|raised back of hand|stop|t\n✋|raised hand|high five stop|t\n🖖|vulcan salute|spock|t\n👌|ok hand|okay perfect|t\n🤌|pinched fingers|italian what|t\n🤏|pinching hand|small tiny little|t\n✌️|victory hand|peace two|t\n🤞|crossed fingers|luck hope|t\n🫰|hand with index finger and thumb crossed|love money|t\n🤟|love you gesture|ily|t\n🤘|sign of the horns|rock metal|t\n🤙|call me hand|shaka call|t\n👈|backhand index pointing left|left point|t\n👉|backhand index pointing right|right point|t\n👆|backhand index pointing up|up point above|t\n👇|backhand index pointing down|down point below|t\n☝️|index pointing up|one first|t\n🫵|index pointing at the viewer|you point|t\n👍|thumbs up|yes approve like agree +1|t\n👎|thumbs down|no disapprove dislike -1|t\n✊|raised fist|power solidarity|t\n👊|oncoming fist|punch bump|t\n👏|clapping hands|applause bravo congrats|t\n🙌|raising hands|celebrate hooray praise|t\n🫶|heart hands|love thanks|t\n👐|open hands|hug jazz|t\n🤲|palms up together|prayer offer|t\n🙏|folded hands|please thanks pray|t\n✍️|writing hand|write sign|t\n💪|flexed biceps|strong muscle|t\n🤝|handshake|deal agreement partner\n👀|eyes|look watching see\n🧠|brain|smart think idea\n🫂|people hugging|hug support comfort\n🧑‍💻|technologist|developer engineer coder laptop|t\n🧑‍🎨|artist|designer painter creative|t\n🧑‍🔬|scientist|research lab|t\n🧑‍🚀|astronaut|space rocket|t\n🧑‍🍳|cook|chef kitchen|t\n🙋|person raising hand|question me volunteer|t\n🙇|person bowing|sorry respect thanks|t\n🤷|person shrugging|dunno whatever shrug|t\n🤦|person facepalming|facepalm ugh|t\n💁|person tipping hand|info sass|t\n🏃|person running|run hurry late|t`,\n  nature: `\n🐶|dog face|puppy pet\n🐱|cat face|kitty pet\n🐭|mouse face|mouse\n🐰|rabbit face|bunny\n🦊|fox|fox clever\n🐻|bear|bear\n🐼|panda|panda\n🐨|koala|koala\n🐯|tiger face|tiger\n🦁|lion|lion king\n🐮|cow face|cow\n🐷|pig face|pig\n🐸|frog|frog\n🐵|monkey face|monkey\n🙈|see no evil monkey|oops shy\n🐔|chicken|chicken\n🐧|penguin|penguin\n🦉|owl|owl wise night\n🐝|honeybee|bee busy\n🦋|butterfly|butterfly\n🐢|turtle|slow turtle\n🐍|snake|snake python\n🐙|octopus|octopus\n🐬|dolphin|dolphin\n🐳|spouting whale|whale docker\n🦄|unicorn|unicorn magic startup\n🌵|cactus|desert plant\n🌲|evergreen tree|tree pine forest\n🌴|palm tree|beach tropical\n🌱|seedling|grow sprout new\n🌿|herb|leaf plant\n🍀|four leaf clover|luck lucky\n🍁|maple leaf|autumn fall canada\n🌸|cherry blossom|flower spring\n🌻|sunflower|flower summer\n🌹|rose|flower love\n🌈|rainbow|pride colors\n☀️|sun|sunny weather\n🌙|crescent moon|night moon\n⭐|star|favorite star\n⚡|high voltage|lightning fast zap\n❄️|snowflake|cold winter freeze\n🔥|fire|hot lit flame\n🌊|water wave|ocean sea wave\n💧|droplet|water drop`,\n  food: `\n🍎|red apple|apple fruit\n🍐|pear|pear fruit\n🍊|tangerine|orange fruit\n🍋|lemon|lemon sour\n🍌|banana|banana fruit\n🍉|watermelon|watermelon summer\n🍇|grapes|grapes wine\n🍓|strawberry|strawberry berry\n🫐|blueberries|blueberry\n🍒|cherries|cherry\n🍑|peach|peach\n🥭|mango|mango\n🍍|pineapple|pineapple\n🥥|coconut|coconut\n🥑|avocado|avocado toast\n🍅|tomato|tomato\n🥕|carrot|carrot vegetable\n🌽|ear of corn|corn\n🌶️|hot pepper|spicy chili\n🥐|croissant|breakfast pastry french\n🍞|bread|toast loaf\n🧀|cheese wedge|cheese\n🥚|egg|egg breakfast\n🥓|bacon|bacon breakfast\n🥞|pancakes|breakfast pancakes\n🍔|hamburger|burger\n🍟|french fries|fries chips\n🍕|pizza|pizza slice\n🌮|taco|taco mexican\n🌯|burrito|burrito wrap\n🥗|green salad|salad healthy\n🍣|sushi|sushi japanese\n🍜|steaming bowl|ramen noodles\n🍝|spaghetti|pasta\n🍩|doughnut|donut sweet\n🍪|cookie|cookie biscuit\n🎂|birthday cake|birthday cake\n🍰|shortcake|cake dessert\n🍫|chocolate bar|chocolate sweet\n🍿|popcorn|movie snack\n☕|hot beverage|coffee tea morning\n🍵|teacup without handle|tea matcha\n🧋|bubble tea|boba\n🥤|cup with straw|soda drink\n🍺|beer mug|beer drink\n🍷|wine glass|wine\n🥂|clinking glasses|cheers toast celebrate\n🍾|bottle with popping cork|champagne celebrate launch`,\n  activity: `\n⚽|soccer ball|football sport\n🏀|basketball|basketball sport\n🏈|american football|football\n⚾|baseball|baseball\n🎾|tennis|tennis\n🏐|volleyball|volleyball\n🏓|ping pong|table tennis\n⛳|flag in hole|golf\n🎯|direct hit|target bullseye goal\n🏆|trophy|win award champion\n🥇|1st place medal|gold first winner\n🥈|2nd place medal|silver second\n🥉|3rd place medal|bronze third\n🏅|sports medal|medal award\n🎮|video game|gaming controller\n🕹️|joystick|arcade game\n🎲|game die|dice random\n🧩|puzzle piece|puzzle integration\n♟️|chess pawn|chess strategy\n🎨|artist palette|design art paint\n🎬|clapper board|film movie action\n🎤|microphone|sing karaoke\n🎧|headphone|music listen\n🎸|guitar|music rock\n🎹|musical keyboard|piano music\n🎉|party popper|celebrate congrats tada\n🎊|confetti ball|celebrate party\n🎁|wrapped gift|present gift birthday\n🎈|balloon|party birthday`,\n  travel: `\n🚗|automobile|car drive\n🚕|taxi|cab\n🚌|bus|bus transit\n🚲|bicycle|bike cycle\n🛴|kick scooter|scooter\n🚄|high speed train|train fast\n✈️|airplane|flight travel plane\n🛫|airplane departure|takeoff flight\n🚀|rocket|launch ship deploy space\n🛸|flying saucer|ufo\n🚁|helicopter|helicopter\n⛵|sailboat|boat sail\n🚢|ship|cruise boat\n⚓|anchor|anchor harbour\n🗺️|world map|map travel\n🧭|compass|direction navigate\n🏔️|snow capped mountain|mountain\n🏕️|camping|camp tent\n🏖️|beach with umbrella|beach holiday vacation\n🏝️|desert island|island vacation\n🏙️|cityscape|city skyline\n🏠|house|home\n🏢|office building|office work company\n🏰|castle|castle\n🗽|statue of liberty|new york\n🌍|globe showing europe africa|world earth global\n🌎|globe showing americas|world earth global\n🌏|globe showing asia australia|world earth global`,\n  objects: `\n💻|laptop|computer work mac\n⌨️|keyboard|type keyboard\n🖥️|desktop computer|monitor screen\n🖱️|computer mouse|mouse click\n📱|mobile phone|phone iphone\n☎️|telephone|phone call\n📷|camera|photo picture\n🎥|movie camera|video film\n📺|television|tv\n🔋|battery|battery power\n🔌|electric plug|plug power\n💡|light bulb|idea bright\n🔦|flashlight|torch\n🕯️|candle|candle light\n📚|books|library read study\n📖|open book|read docs\n📝|memo|note write todo\n✏️|pencil|edit write\n🖊️|pen|write sign\n📌|pushpin|pin important\n📎|paperclip|attach attachment\n✂️|scissors|cut\n📁|file folder|folder directory\n🗂️|card index dividers|organize files\n📅|calendar|date schedule\n📊|bar chart|stats analytics\n📈|chart increasing|growth up metrics\n📉|chart decreasing|down drop metrics\n🔒|locked|lock secure private\n🔓|unlocked|unlock open\n🔑|key|password key access\n🔨|hammer|build fix\n🛠️|hammer and wrench|tools fix build\n⚙️|gear|settings config\n🧪|test tube|test experiment lab\n🔬|microscope|research science\n💊|pill|medicine\n💰|money bag|money revenue\n💳|credit card|payment card billing\n📦|package|box ship delivery release\n✉️|envelope|email mail letter\n📣|megaphone|announce announcement\n🔔|bell|notification alert\n⏰|alarm clock|alarm time wake\n⏳|hourglass not done|waiting time loading\n🗑️|wastebasket|trash delete bin`,\n  symbols: `\n❤️|red heart|love like\n🧡|orange heart|love\n💛|yellow heart|love\n💚|green heart|love\n💙|blue heart|love\n💜|purple heart|love\n🖤|black heart|love\n🤍|white heart|love\n💔|broken heart|heartbreak sad\n❤️‍🔥|heart on fire|passion love\n💯|hundred points|100 perfect score\n✅|check mark button|done yes complete\n☑️|check box with check|done checked\n✔️|check mark|done yes\n❌|cross mark|no wrong cancel\n❎|cross mark button|no cancel\n➕|plus|add plus\n➖|minus|subtract minus\n❓|red question mark|question help\n❗|red exclamation mark|important alert\n‼️|double exclamation mark|urgent\n⚠️|warning|caution alert\n🚫|prohibited|forbidden no ban\n⛔|no entry|stop blocked\n🔴|red circle|red status\n🟠|orange circle|orange status\n🟡|yellow circle|yellow status\n🟢|green circle|green status online\n🔵|blue circle|blue status\n🟣|purple circle|purple\n⚫|black circle|black\n⚪|white circle|white\n♻️|recycling symbol|recycle reuse\n🆕|new button|new\n🆗|ok button|ok okay\n🔝|top arrow|top\n▶️|play button|play start\n⏸️|pause button|pause\n⏹️|stop button|stop\n🔁|repeat button|repeat loop retry\n➡️|right arrow|right next\n⬅️|left arrow|left back\n⬆️|up arrow|up\n⬇️|down arrow|down\n💤|zzz|sleep\n💬|speech balloon|comment chat message\n💭|thought balloon|thinking\n👁️‍🗨️|eye in speech bubble|witness`,\n  flags: `\n🏁|chequered flag|finish race done\n🚩|triangular flag|red flag warning\n🏳️|white flag|surrender\n🏴|black flag|flag\n🏳️‍🌈|rainbow flag|pride lgbt\n🇺🇸|flag united states|usa america us\n🇬🇧|flag united kingdom|uk britain gb\n🇨🇦|flag canada|canada ca\n🇩🇪|flag germany|germany de\n🇫🇷|flag france|france fr\n🇪🇸|flag spain|spain es\n🇮🇹|flag italy|italy it\n🇳🇱|flag netherlands|netherlands nl dutch\n🇸🇪|flag sweden|sweden se\n🇯🇵|flag japan|japan jp\n🇰🇷|flag south korea|korea kr\n🇮🇳|flag india|india in\n🇧🇷|flag brazil|brazil br\n🇲🇽|flag mexico|mexico mx\n🇦🇺|flag australia|australia au`,\n};\n\nconst categories: { key: EmojiCategoryKey; label: string }[] = [\n  { key: \"smileys\", label: \"Smileys\" },\n  { key: \"people\", label: \"People & hands\" },\n  { key: \"nature\", label: \"Animals & nature\" },\n  { key: \"food\", label: \"Food & drink\" },\n  { key: \"activity\", label: \"Activities\" },\n  { key: \"travel\", label: \"Travel & places\" },\n  { key: \"objects\", label: \"Objects\" },\n  { key: \"symbols\", label: \"Symbols\" },\n  { key: \"flags\", label: \"Flags\" },\n];\n\nconst emojis: EmojiEntry[] = categories.flatMap(({ key }) =>\n  raw[key]\n    .trim()\n    .split(\"\\n\")\n    .map((line) => {\n      const [emoji, name, keywords, tone] = line.split(\"|\");\n      return { emoji, name, keywords, tone: tone === \"t\", category: key };\n    }),\n);\n\n/* ───────────────────────── skin tones ───────────────────────── */\n\nconst TONES = [\"\", \"\\u{1F3FB}\", \"\\u{1F3FC}\", \"\\u{1F3FD}\", \"\\u{1F3FE}\", \"\\u{1F3FF}\"] as const;\nconst TONE_NAMES = [\"Default\", \"Light\", \"Medium-light\", \"Medium\", \"Medium-dark\", \"Dark\"];\nexport type SkinTone = 0 | 1 | 2 | 3 | 4 | 5;\n\n/** Puts a skin tone modifier after the first code point (dropping a variation selector there), which also works for ZWJ people like 🧑‍💻. */\nexport function withSkinTone(emoji: string, tone: SkinTone) {\n  if (!tone) return emoji;\n  const [first, ...rest] = [...emoji];\n  if (rest[0] === \"️\") rest.shift();\n  return first + TONES[tone] + rest.join(\"\");\n}\n\nexport type PickedEmoji = { emoji: string; base: string; name: string; shortcode: string };\nconst shortcode = (name: string) => `:${name.toLowerCase().replace(/[^a-z0-9]+/g, \"_\").replace(/^_|_$/g, \"\")}:`;\nconst byGlyph = new Map(emojis.map((e) => [e.emoji, e]));\n\nfunction search(q: string) {\n  const query = q.trim().toLowerCase();\n  if (!query) return [];\n  const direct = byGlyph.get(query);\n  if (direct) return [direct];\n  const tokens = query.replace(/^:|:$/g, \"\").split(/[\\s_]+/).filter(Boolean);\n  const scored: { e: EmojiEntry; score: number }[] = [];\n  for (const e of emojis) {\n    const hay = ` ${e.name} ${e.keywords} `.toLowerCase();\n    if (!tokens.every((t) => hay.includes(` ${t}`))) continue;\n    // Names that start with the query first, then names that contain it, then keyword hits.\n    const score = e.name.startsWith(tokens[0]) ? 0 : ` ${e.name}`.includes(` ${tokens[0]}`) ? 1 : 2;\n    scored.push({ e, score });\n  }\n  return scored.sort((a, b) => a.score - b.score).map((s) => s.e);\n}\n\n/* ───────────────────────── icons ───────────────────────── */\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\nconst CategoryIcon = ({ k }: { k: string }) => {\n  switch (k) {\n    case \"recent\": return <Clock />;\n    case \"smileys\": return <svg {...svg}><circle cx=\"8\" cy=\"8\" r=\"5.75\" /><path d=\"M5.75 9.5a2.75 2.75 0 0 0 4.5 0\" /><circle cx=\"6\" cy=\"6.75\" r=\".6\" fill=\"currentColor\" stroke=\"none\" /><circle cx=\"10\" cy=\"6.75\" r=\".6\" fill=\"currentColor\" stroke=\"none\" /></svg>;\n    case \"people\": return <svg {...svg}><path d=\"M6 8.5V4a1 1 0 0 1 2 0v3.5M8 7V3.25a1 1 0 0 1 2 0V7.5M10 7.5V4.5a1 1 0 0 1 2 0v4.25a5 5 0 0 1-5 5 4.5 4.5 0 0 1-3.6-1.8L2.3 10.4a1 1 0 0 1 1.5-1.3L6 10.5\" /></svg>;\n    case \"nature\": return <svg {...svg}><path d=\"M3.5 12.5c0-5 3.5-8.5 9-9-.5 5.5-4 9-9 9zM3.5 12.5 8 8\" /></svg>;\n    case \"food\": return <svg {...svg}><path d=\"M3.5 6.5h8v2.25a4 4 0 0 1-8 0zM11.5 7h.75a1.75 1.75 0 0 1 0 3.5h-1.1M6 2.75v1.5M8.5 2.75v1.5\" /></svg>;\n    case \"activity\": return <svg {...svg}><circle cx=\"8\" cy=\"8\" r=\"5.75\" /><path d=\"M2.5 6.5c2 .5 4 2.5 4.5 7M13.5 9.5c-2-.5-4-2.5-4.5-7M4 3.75c1.5 2.5 5.5 6 8 8.5\" /></svg>;\n    case \"travel\": return <svg {...svg}><path d=\"M3 10.5 4.25 6.5a1.5 1.5 0 0 1 1.4-1h4.7a1.5 1.5 0 0 1 1.4 1L13 10.5M2.75 10.5h10.5v2h-10.5zM4.5 12.5v1M11.5 12.5v1\" /></svg>;\n    case \"objects\": return <svg {...svg}><path d=\"M6 11.5h4M6.5 13.5h3M8 2.5a4 4 0 0 0-2.4 7.2c.3.25.4.55.4.9v.9h4v-.9c0-.35.1-.65.4-.9A4 4 0 0 0 8 2.5z\" /></svg>;\n    case \"symbols\": return <svg {...svg}><path d=\"M8 13.25S2.25 10 2.25 6.1A2.85 2.85 0 0 1 8 4.9a2.85 2.85 0 0 1 5.75 1.2C13.75 10 8 13.25 8 13.25z\" /></svg>;\n    case \"flags\": return <svg {...svg}><path d=\"M3.5 13.75V2.75M3.5 3.25h8l-1.75 2.75 1.75 2.75h-8\" /></svg>;\n    default: return <Search />;\n  }\n};\n\nfunction SmileyPlus() {\n  return (\n    <svg {...svg}>\n      <path d=\"M13.6 7.1A5.75 5.75 0 1 1 8.9 2.3\" />\n      <path d=\"M5.75 9.5a2.75 2.75 0 0 0 4.5 0\" />\n      <circle cx=\"6\" cy=\"6.75\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      <circle cx=\"10\" cy=\"6.75\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      <path d=\"M12.25 1.75v3.5M10.5 3.5H14\" />\n    </svg>\n  );\n}\n\n/* ───────────────────────── the panel ───────────────────────── */\n\nconst COLS = 8;\nconst CELL = 34;\ntype Section = { key: string; label: string; items: EmojiEntry[] };\ntype Cell = { id: string; entry: EmojiEntry; row: number; col: number };\n\nexport type EmojiPickerPanelProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  onEmojiSelect?: (picked: PickedEmoji) => void;\n  skinTone?: SkinTone;\n  defaultSkinTone?: SkinTone;\n  onSkinToneChange?: (tone: SkinTone) => void;\n  /** Base glyphs, most recent first. The row reorders the next time the panel mounts, so nothing moves under the pointer. */\n  recent?: string[];\n  defaultRecent?: string[];\n  onRecentChange?: (recent: string[]) => void;\n  recentLimit?: number;\n  /** Focus the search field on mount. */\n  autoFocus?: boolean;\n};\n\nexport function EmojiPickerPanel({\n  onEmojiSelect,\n  skinTone: toneProp,\n  defaultSkinTone = 0,\n  onSkinToneChange,\n  recent: recentProp,\n  defaultRecent = [],\n  onRecentChange,\n  recentLimit = 16,\n  autoFocus = false,\n  className,\n  ...rest\n}: EmojiPickerPanelProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [tone, setTone] = useControllableState<SkinTone>({ value: toneProp, defaultValue: defaultSkinTone, onChange: onSkinToneChange });\n  const [recent, setRecent] = useControllableState({ value: recentProp, defaultValue: defaultRecent, onChange: onRecentChange });\n  // The recent row as it was when the panel opened.\n  const [shownRecent] = useState(recent);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState<{ id: string; via: \"pointer\" | \"keyboard\" } | null>(null);\n  const [gridMode, setGridMode] = useState(false);\n  const [spy, setSpy] = useState<string>(shownRecent.length ? \"recent\" : \"smileys\");\n  const [toneOpen, setToneOpen] = useState(false);\n\n  const scroller = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const toneButton = useRef<HTMLButtonElement>(null);\n  const jumpLock = useRef<number>(undefined);\n  const hx = useMotionValue(0);\n  const hy = useMotionValue(0);\n  const shownOnce = useRef(false);\n\n  const searching = query.trim().length > 0;\n  const sections: Section[] = useMemo(() => {\n    if (searching) return [{ key: \"results\", label: \"Results\", items: search(query) }];\n    const recentItems = shownRecent.map((g) => byGlyph.get(g)).filter((e): e is EmojiEntry => !!e);\n    return [\n      ...(recentItems.length ? [{ key: \"recent\", label: \"Recent\", items: recentItems }] : []),\n      ...categories.map((c) => ({ key: c.key, label: c.label, items: emojis.filter((e) => e.category === c.key) })),\n    ];\n  }, [searching, query, shownRecent]);\n\n  // Rows across every section, for arrow keys that cross section boundaries.\n  const { rows, byId } = useMemo(() => {\n    const rows: Cell[][] = [];\n    const byId = new Map<string, Cell>();\n    for (const s of sections) {\n      for (let i = 0; i < s.items.length; i += COLS) {\n        const row = s.items.slice(i, i + COLS).map((entry, c) => {\n          const cell = { id: `${uid}-${s.key}-${i + c}`, entry, row: rows.length, col: c };\n          byId.set(cell.id, cell);\n          return cell;\n        });\n        rows.push(row);\n      }\n    }\n    return { rows, byId };\n  }, [sections, uid]);\n\n  const activeCell = active ? byId.get(active.id) : undefined;\n  const preview = activeCell?.entry;\n\n  // Glide one highlight to the active cell. Keyboard moves and first appearances jump.\n  useLayoutEffect(() => {\n    const el = activeCell ? document.getElementById(activeCell.id) : null;\n    if (!el) return;\n    const x = el.offsetLeft, y = el.offsetTop;\n    if (active?.via === \"pointer\" && shownOnce.current && !reduce) {\n      const a = animate(hx, x, spring.follow);\n      const b = animate(hy, y, spring.follow);\n      return () => {\n        a.stop();\n        b.stop();\n      };\n    }\n    hx.set(x);\n    hy.set(y);\n    shownOnce.current = true;\n    if (active?.via === \"keyboard\") el.scrollIntoView({ block: \"nearest\" });\n  }, [activeCell, active?.via, hx, hy, reduce]);\n\n  useLayoutEffect(() => {\n    if (autoFocus) inputRef.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n  useLayoutEffect(() => () => window.clearTimeout(jumpLock.current), []);\n\n  const pick = (entry: EmojiEntry) => {\n    const glyph = entry.tone ? withSkinTone(entry.emoji, tone) : entry.emoji;\n    setRecent([entry.emoji, ...recent.filter((g) => g !== entry.emoji)].slice(0, recentLimit));\n    onEmojiSelect?.({ emoji: glyph, base: entry.emoji, name: entry.name, shortcode: shortcode(entry.name) });\n  };\n\n  const moveTo = (cell: Cell | undefined) => cell && setActive({ id: cell.id, via: \"keyboard\" });\n\n  const onSearchKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const cur = activeCell;\n    const grid = !query || gridMode;\n    switch (e.key) {\n      case \"ArrowDown\": {\n        e.preventDefault();\n        setGridMode(true);\n        if (!cur) return moveTo(rows[0]?.[0]);\n        const next = rows[cur.row + 1];\n        return moveTo(next?.[Math.min(cur.col, next.length - 1)]);\n      }\n      case \"ArrowUp\": {\n        e.preventDefault();\n        setGridMode(true);\n        if (!cur) return;\n        const prev = rows[cur.row - 1];\n        return moveTo(prev?.[Math.min(cur.col, prev.length - 1)]);\n      }\n      case \"ArrowRight\":\n      case \"ArrowLeft\": {\n        if (!grid) return;\n        e.preventDefault();\n        setGridMode(true);\n        const flat = rows.flat();\n        const i = cur ? flat.findIndex((c) => c.id === cur.id) : -1;\n        return moveTo(flat[Math.max(0, Math.min(flat.length - 1, i + (e.key === \"ArrowRight\" ? 1 : -1)))]);\n      }\n      case \"Enter\": {\n        e.preventDefault();\n        const target = cur?.entry ?? (searching ? rows[0]?.[0]?.entry : undefined);\n        if (target) pick(target);\n        return;\n      }\n      case \"Escape\":\n        if (query) {\n          // The first Escape clears the search; the next one closes whatever holds the panel.\n          e.preventDefault();\n          e.stopPropagation();\n          setQuery(\"\");\n          setActive(null);\n        }\n        return;\n    }\n  };\n\n  const onScroll = () => {\n    if (jumpLock.current !== undefined || searching) return;\n    const el = scroller.current;\n    if (!el) return;\n    let current: string | undefined = sections[0]?.key;\n    for (const s of el.querySelectorAll<HTMLElement>(\"[data-section]\")) {\n      if (s.offsetTop - 1 <= el.scrollTop) current = s.dataset.section;\n    }\n    if (current && current !== spy) setSpy(current);\n  };\n\n  const jump = (key: string, instant: boolean) => {\n    const el = scroller.current;\n    const target = el?.querySelector<HTMLElement>(`[data-section=\"${key}\"]`);\n    if (!el || !target) return;\n    setSpy(key);\n    window.clearTimeout(jumpLock.current);\n    // Hold the indicator on the target while a smooth scroll passes the sections in between.\n    jumpLock.current = window.setTimeout(() => (jumpLock.current = undefined), instant || reduce ? 50 : 700);\n    el.scrollTo({ top: target.offsetTop, behavior: instant || reduce ? \"auto\" : \"smooth\" });\n  };\n\n  const tabKeys = [...(shownRecent.length ? [\"recent\"] : []), ...categories.map((c) => c.key)];\n  const tabLabel = (k: string) => (k === \"recent\" ? \"Recent\" : categories.find((c) => c.key === k)!.label);\n\n  return (\n    <div className={cn(\"flex w-[296px] max-w-full flex-col overflow-hidden\", className)} {...rest}>\n      {/* Search and skin tone */}\n      <div className=\"relative flex items-center gap-2 p-2 pb-1.5\">\n        <label className=\"flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg border border-line-2 bg-raised px-2.5 transition-[border-color,box-shadow] duration-150 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\">\n          <Search size={14} className=\"shrink-0 text-fg-4\" />\n          <span className=\"sr-only\">Search emoji</span>\n          <input\n            ref={inputRef}\n            value={query}\n            onChange={(e) => {\n              const q = e.target.value;\n              setQuery(q);\n              setGridMode(false);\n              // While searching, the first result is ready for Enter.\n              const first = q.trim() ? search(q)[0] : undefined;\n              setActive(first ? { id: `${uid}-results-0`, via: \"keyboard\" } : null);\n              scroller.current?.scrollTo({ top: 0 });\n            }}\n            onKeyDown={onSearchKey}\n            role=\"combobox\"\n            aria-expanded\n            aria-haspopup=\"grid\"\n            aria-controls={`${uid}-grid`}\n            aria-activedescendant={activeCell?.id}\n            aria-autocomplete=\"list\"\n            placeholder=\"Search emoji\"\n            autoComplete=\"off\"\n            spellCheck={false}\n            enterKeyHint=\"search\"\n            className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n          />\n        </label>\n        <button\n          ref={toneButton}\n          type=\"button\"\n          onClick={() => setToneOpen(true)}\n          aria-label={`Skin tone: ${TONE_NAMES[tone]}`}\n          aria-expanded={toneOpen}\n          className=\"relative grid size-8 shrink-0 place-items-center rounded-lg text-[18px] leading-none outline-none transition-[background-color,scale] duration-150 hover:bg-fg/5 active:scale-[0.92] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span key={tone} initial={reduce ? { opacity: 0 } : swap.initial} animate={swap.animate} exit={reduce ? { opacity: 0 } : swap.exit} transition={reduce ? { duration: 0.12 } : spring.pop}>\n              {withSkinTone(\"✋\", tone)}\n            </motion.span>\n          </AnimatePresence>\n        </button>\n\n        <AnimatePresence>\n          {toneOpen && (\n            <ToneStrip\n              tone={tone}\n              reduce={!!reduce}\n              onChoose={(t) => {\n                setTone(t);\n                setToneOpen(false);\n                toneButton.current?.focus();\n              }}\n              onDismiss={() => {\n                setToneOpen(false);\n                toneButton.current?.focus();\n              }}\n            />\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Category jumps; the pill follows the scroll. */}\n      <div role=\"toolbar\" aria-label=\"Emoji categories\" className={cn(\"flex items-center justify-between px-2 pb-1 transition-opacity duration-150\", searching && \"pointer-events-none opacity-40\")}>\n        {tabKeys.map((k) => {\n          const on = !searching && spy === k;\n          return (\n            <button\n              key={k}\n              type=\"button\"\n              aria-label={tabLabel(k)}\n              title={tabLabel(k)}\n              aria-current={on || undefined}\n              tabIndex={searching ? -1 : 0}\n              onClick={(e) => jump(k, e.detail === 0)}\n              className={cn(\n                \"relative grid size-7 place-items-center rounded-md outline-none transition-[color,scale] duration-150 active:scale-[0.9] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                on ? \"text-fg\" : \"text-fg-3 hover:text-fg-2\",\n              )}\n            >\n              {on && <motion.span layoutId={reduce ? undefined : `${uid}-tab`} aria-hidden className=\"absolute inset-0 rounded-md bg-fg/[0.08]\" transition={spring.snappy} />}\n              <span className=\"relative\">\n                <CategoryIcon k={k} />\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      {/* The grid. Focus stays in search; arrows move the active cell. */}\n      <div\n        ref={scroller}\n        onScroll={onScroll}\n        className=\"relative h-[264px] overflow-y-auto overscroll-contain border-t border-line px-1 pb-2 [scrollbar-gutter:stable_both-edges]\"\n      >\n        <div id={`${uid}-grid`} role=\"grid\" aria-label={searching ? \"Search results\" : \"Emoji\"} aria-rowcount={rows.length} className=\"relative\">\n          {activeCell && (\n            <motion.div\n              aria-hidden\n              className=\"pointer-events-none absolute left-0 top-0 rounded-lg bg-fg/[0.08]\"\n              style={{ x: hx, y: hy, width: CELL, height: CELL }}\n            />\n          )}\n          {sections.map((s) => (\n            <div key={s.key} role=\"rowgroup\" aria-label={s.label} data-section={s.key}>\n              <div aria-hidden className=\"sticky top-0 z-10 flex h-7 items-end bg-raised px-1 pb-1 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-4\">\n                {s.key === \"results\" ? (s.items.length === 1 ? \"1 result\" : `${s.items.length} results`) : s.label}\n              </div>\n              {s.items.length === 0 && s.key === \"results\" && (\n                <div className=\"flex flex-col items-center gap-1 px-4 pb-6 pt-10 text-center\">\n                  <p className=\"text-[13px] text-fg-2\">No emoji match “{query.trim()}”</p>\n                  <p className=\"text-[12px] text-fg-3\">Try a feeling or an object, like “party” or “rocket”.</p>\n                </div>\n              )}\n              {rows\n                .filter((r) => r[0] && r[0].id.startsWith(`${uid}-${s.key}-`))\n                .map((r) => (\n                  <div key={r[0].id} role=\"row\" className=\"flex\">\n                    {r.map((c) => {\n                      const glyph = c.entry.tone ? withSkinTone(c.entry.emoji, tone) : c.entry.emoji;\n                      return (\n                        <div\n                          key={c.id}\n                          id={c.id}\n                          role=\"gridcell\"\n                          aria-label={c.entry.name}\n                          aria-selected={activeCell?.id === c.id}\n                          onPointerEnter={() => setActive({ id: c.id, via: \"pointer\" })}\n                          onMouseDown={(e) => e.preventDefault()}\n                          onClick={() => pick(c.entry)}\n                          className=\"group/cell relative grid shrink-0 cursor-default scroll-mt-8 scroll-mb-1 select-none place-items-center\"\n                          style={{ width: CELL, height: CELL }}\n                        >\n                          <span className=\"text-[22px] leading-none transition-[scale] duration-100 ease-out group-active/cell:scale-[0.82]\">{glyph}</span>\n                        </div>\n                      );\n                    })}\n                  </div>\n                ))}\n            </div>\n          ))}\n        </div>\n      </div>\n\n      {/* Preview of what Enter or a click will insert. */}\n      <div className=\"flex h-11 shrink-0 items-center gap-2.5 border-t border-line px-3\">\n        {preview ? (\n          <>\n            <span className=\"text-[24px] leading-none\" aria-hidden>\n              {preview.tone ? withSkinTone(preview.emoji, tone) : preview.emoji}\n            </span>\n            <div className=\"flex min-w-0 flex-col\">\n              <span className=\"truncate text-[12.5px] font-medium first-letter:uppercase text-fg\">{preview.name}</span>\n              <span className=\"truncate font-mono text-[11px] text-fg-3\">{shortcode(preview.name)}</span>\n            </div>\n          </>\n        ) : (\n          <span className=\"text-[12px] text-fg-3\">Pick an emoji, or search by name</span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction ToneStrip({ tone, reduce, onChoose, onDismiss }: { tone: SkinTone; reduce: boolean; onChoose: (t: SkinTone) => void; onDismiss: () => void }) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [focus, setFocus] = useState<number>(tone);\n  useLayoutEffect(() => {\n    refs.current[focus]?.focus();\n  }, [focus]);\n  return (\n    <motion.div\n      role=\"radiogroup\"\n      aria-label=\"Skin tone\"\n      className=\"absolute inset-y-2 right-2 z-20 flex items-center justify-end gap-0.5 rounded-lg border border-line-2 bg-raised px-1 shadow-pop\"\n      initial={reduce ? { opacity: 0 } : { clipPath: \"inset(0 0 0 calc(100% - 32px) round 8px)\", opacity: 0.6 }}\n      animate={{ clipPath: \"inset(0 0 0 0% round 8px)\", opacity: 1 }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { clipPath: \"inset(0 0 0 calc(100% - 32px) round 8px)\", opacity: 0, transition: { duration: 0.14, ease: ease.in } }}\n      transition={{ duration: 0.22, ease: ease.out }}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") {\n          e.preventDefault();\n          e.stopPropagation();\n          onDismiss();\n        } else if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n          e.preventDefault();\n          setFocus((f) => (f + (e.key === \"ArrowRight\" ? 1 : -1) + 6) % 6);\n        }\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node)) onDismiss();\n      }}\n    >\n      {TONES.map((_, t) => (\n        <motion.button\n          key={t}\n          ref={(el) => {\n            refs.current[t] = el;\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={tone === t}\n          aria-label={TONE_NAMES[t]}\n          tabIndex={focus === t ? 0 : -1}\n          onClick={() => onChoose(t as SkinTone)}\n          initial={reduce ? false : { opacity: 0, x: 6 }}\n          animate={{ opacity: 1, x: 0 }}\n          transition={{ duration: 0.2, ease: ease.out, delay: reduce ? 0 : (5 - t) * 0.018 }}\n          className=\"relative grid size-7 place-items-center rounded-md text-[18px] leading-none outline-none transition-[background-color,scale] duration-150 hover:bg-fg/5 active:scale-[0.88] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 aria-checked:bg-fg/[0.08]\"\n        >\n          {withSkinTone(\"✋\", t as SkinTone)}\n        </motion.button>\n      ))}\n    </motion.div>\n  );\n}\n\n/* ───────────────────────── the popover ───────────────────────── */\n\nexport type EmojiPickerProps = Omit<EmojiPickerPanelProps, \"className\" | \"autoFocus\"> & {\n  /** Your own trigger element. Defaults to an \"Add reaction\" icon button. */\n  trigger?: React.ReactElement<Record<string, unknown>>;\n  /** Accessible name for the default trigger and the popup. */\n  label?: string;\n  /** Close after a pick. Turn off for composers where people insert several in a row. */\n  closeOnSelect?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  className?: string;\n};\n\nexport function EmojiPicker({\n  trigger,\n  label = \"Add reaction\",\n  closeOnSelect = true,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"start\",\n  className,\n  onEmojiSelect,\n  recent: recentProp,\n  defaultRecent = [],\n  onRecentChange,\n  skinTone: toneProp,\n  defaultSkinTone = 0,\n  onSkinToneChange,\n  ...panel\n}: EmojiPickerProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  // Lifted here so they outlive the popup unmounting between opens.\n  const [recent, setRecent] = useControllableState({ value: recentProp, defaultValue: defaultRecent, onChange: onRecentChange });\n  const [tone, setTone] = useControllableState<SkinTone>({ value: toneProp, defaultValue: defaultSkinTone, onChange: onSkinToneChange });\n\n  return (\n    <Popover.Root open={open} onOpenChange={setOpen}>\n      {trigger ? (\n        <Popover.Trigger render={trigger} />\n      ) : (\n        <Popover.Trigger\n          aria-label={label}\n          className={cn(\n            \"grid size-8 place-items-center rounded-lg text-fg-3 outline-none transition-[background-color,color,scale] duration-150 hover:bg-fg/5 hover:text-fg active:scale-[0.92] data-[popup-open]:bg-fg/5 data-[popup-open]:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            className,\n          )}\n        >\n          <SmileyPlus />\n        </Popover.Trigger>\n      )}\n      <Popover.Portal>\n        <Popover.Positioner side={side} align={align} sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            aria-label={label}\n            className={cn(\n              \"max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-180 ease-out-expo data-starting-style:scale-[0.96] data-starting-style:opacity-0\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <EmojiPickerPanel\n              {...panel}\n              recent={recent}\n              onRecentChange={setRecent}\n              skinTone={tone}\n              onSkinToneChange={setTone}\n              onEmojiSelect={(p) => {\n                onEmojiSelect?.(p);\n                if (closeOnSelect) setOpen(false);\n              }}\n            />\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/emoji-picker"
}
