# EmojiPicker

> Emoji picker in a popover, with search, categories, skin tones, RTL-aware keyboard navigation and lazily loaded emoji data.

Source: https://docs.nasaqui.com/components/emoji-picker

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/emoji-picker.json
```

A styled emoji picker built on [`frimousse`](https://frimousse.liveblocks.io) (headless, virtualised) and the Nasaq
`Popover`. It gives you a search field, category headers, a grid and a skin tone button. Choosing an emoji calls
`onEmojiSelect` and closes the popover.

## Decision and bundle weight

The issue asked to evaluate the dependency first. `frimousse@0.4.0`: 27 KB minified (9.8 KB gzip), zero
dependencies, `sideEffects: false` (tree-shakable), peer `react ^18 || ^19`, TypeScript 5.1+. It does **not** bundle
emoji data: the dataset (Emojibase) is fetched from the jsDelivr CDN the first time a picker opens and cached in
the browser, so it adds nothing to your JS payload. Building our own picker would have needed the same lazy
dataset plus virtualisation and keyboard handling, so we adopted it.

Limit: Emojibase has no Arabic dataset. In an Arabic UI the picker chrome (search placeholder, empty and loading
text, skin tone label) is Arabic, but emoji names, category names and search keywords stay English. To use another
dataset, pass `resolveEmojiData` (a frimousse option).

## When to use

- Reactions, chat composers, comment boxes, status emoji.

## When not to use

- Inserting text or mentions: use [mention-textarea](https://docs.nasaqui.com/components/mention-textarea).
- A fixed handful of reactions: render buttons yourself.

## Import

```tsx
import { EmojiPicker, EmojiPickerPanel } from "@fadymondy/nasaq/web";
```

## Quick start

```tsx
import { EmojiPicker } from "@fadymondy/nasaq/web";
import { useState } from "react";

export function Composer() {
  const [text, setText] = useState("");
  return (
    <div className="flex items-center gap-2">
      <input value={text} onChange={(e) => setText(e.target.value)} aria-label="Message" />
      <EmojiPicker onEmojiSelect={({ emoji }) => setText((t) => t + emoji)} />
    </div>
  );
}
```

## Anatomy

```
EmojiPicker                         Popover + trigger
  PopoverContent
    EmojiPickerPanel                data-slot="emoji-picker"
      search                        data-slot="emoji-picker-search"
      skin tone button              data-slot="emoji-picker-skin-tone"
      viewport                      data-slot="emoji-picker-viewport"
        category header             data-slot="emoji-picker-category"
        row                         data-slot="emoji-picker-row"
          emoji                     data-slot="emoji-picker-emoji" (data-active on the highlighted one)
```

## API

### EmojiPicker

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onEmojiSelect` | `(emoji: { emoji: string; label: string }) => void` | | Called with the chosen emoji. |
| `trigger` | `ReactElement` | icon `Button` with a smile | The element that opens the picker. Give it an accessible name. |
| `closeOnSelect` | `boolean` | `true` | Close after choosing. |
| `open` / `onOpenChange` | `boolean` / `(open: boolean) => void` | uncontrolled | Controlled open state. |
| `side` / `align` | Popover placement | `"bottom"` / `"start"` | Logical, mirrors in RTL. |
| `locale` | `Locale` | Nasaq locale, else `"en"` | Emoji-data locale. |
| `skinTone` | `"none" \| "light" \| "medium-light" \| "medium" \| "medium-dark" \| "dark"` | `"none"` | Initial skin tone. |
| `columns` | `number` | `8` | Emoji per row. Match it with a `className` width. |
| `labels` | `Partial<{ search; loading; empty(query); skinTone }>` | built-in en/ar | Override strings. |
| `className` | `string` | | Classes for the panel, for example `w-[22rem]`. |

The other frimousse root props (`emojiVersion`, `emojibaseUrl`, `resolveEmojiData`, `sticky`) pass through.

### EmojiPickerPanel

The same props without the popover ones (`trigger`, `open`, `closeOnSelect`, `side`, `align`). Use it inline or
inside your own `Popover`.

### resolveEmojiLocale(locale: string): Locale

Maps a UI locale to an available Emojibase locale (`"ar"` gives `"en"`, `"fr-CA"` gives `"fr"`).

## Examples

Arabic composer:

```tsx
import { EmojiPicker, Field, FieldLabel, Input } from "@fadymondy/nasaq/web";
import { useState } from "react";

export function ArabicComposer() {
  const [text, setText] = useState("");
  return (
    <div lang="ar" dir="rtl" className="flex items-end gap-2">
      <Field className="flex-1">
        <FieldLabel>الرسالة</FieldLabel>
        <Input value={text} onChange={(e) => setText(e.target.value)} />
      </Field>
      <EmojiPicker onEmojiSelect={({ emoji }) => setText((t) => t + emoji)} />
    </div>
  );
}
```

Custom trigger and fixed skin tone:

```tsx
import { Button, EmojiPicker } from "@fadymondy/nasaq/web";

export function React() {
  return (
    <EmojiPicker skinTone="medium" trigger={<Button>React</Button>} onEmojiSelect={({ emoji }) => console.log(emoji)} />
  );
}
```

## Accessibility

| Key | Action |
| --- | --- |
| Type | Filter emoji in the search field. |
| Arrow keys | Move the highlighted emoji. In RTL, ArrowLeft moves toward the visual left, which is the next emoji. |
| Enter | Choose the highlighted emoji. |
| Escape | Close the popover. |

The trigger, search field and skin tone button carry `aria-label`s (localised en/ar; override with `labels`). Give a
custom `trigger` its own accessible name.

## RTL & i18n

- Category headers, search and layout use logical classes and follow `dir`.
- frimousse's horizontal arrows are physical ("left = previous"); the panel swaps them when its computed direction is RTL.
- Chrome strings come from the Nasaq locale (en, ar). Emoji data follows `resolveEmojiLocale`; Arabic falls back to English data (see the limit above).

## Styling & tokens

Uses `bg-popover`, `border-border`, `bg-card`, `text-muted-foreground`, `bg-nq-hover`, `outline-nq-focus`. Target
`data-slot` values from the Anatomy tree; the highlighted emoji has `data-active`. Extend with `className`.

## Do / Don't

- Do pass `onEmojiSelect` and insert at the caret in real inputs.
- Do use `locale` for non-Arabic locales to get translated names.
- Don't fetch emoji data yourself unless you supply `resolveEmojiData`.
- Don't rely on emoji alone to convey meaning; pair with text.

## Related

- [popover](https://docs.nasaqui.com/components/popover)
- [mention-textarea](https://docs.nasaqui.com/components/mention-textarea)

## Lab

https://docs.nasaqui.com/?path=/docs/components-pickers-emojipicker--docs

## Code

### React

```tsx
import { EmojiPicker } from "@fadymondy/nasaq/web";
import { useState } from "react";

export function Composer() {
  const [text, setText] = useState("");
  return (
    <div className="flex items-center gap-2">
      <input value={text} onChange={(e) => setText(e.target.value)} aria-label="Message" />
      <EmojiPicker onEmojiSelect={({ emoji }) => setText((t) => t + emoji)} />
    </div>
  );
}
```

### shadcn

```tsx
import { EmojiPicker } from "@/components/ui/emoji-picker";
import { useState } from "react";

export function Composer() {
  const [text, setText] = useState("");
  return (
    <div className="flex items-center gap-2">
      <input value={text} onChange={(e) => setText(e.target.value)} aria-label="Message" />
      <EmojiPicker onEmojiSelect={({ emoji }) => setText((t) => t + emoji)} />
    </div>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { ref } from "vue";
import { NqEmojiPicker } from "@fadymondy/nasaq/vue";

const text = ref("");
</script>

<template>
  <div class="flex items-center gap-2">
    <input v-model="text" aria-label="Message" />
    <NqEmojiPicker @emoji-select="({ emoji }) => (text += emoji)" />
  </div>
</template>
```

### Blade

```blade
<div x-data="{ text: '' }" class="flex items-center gap-2">
    <input x-model="text" aria-label="Message" class="h-control rounded-control border border-input bg-card px-3 text-body-sm" />
    <x-nq::emoji-picker x-on:emoji-select="text += $event.detail.emoji" />
</div>
```

### HTML + Alpine

```html
<div x-data="{ text: '' }" class="flex items-center gap-2">
    <input x-model="text" aria-label="Message" class="h-control rounded-control border border-input bg-card px-3 text-body-sm" />
    <div data-slot="popover" x-data="nqPopover(false)" x-modelable="open" x-id="['nq-popover']" class="contents">
    <button data-slot="popover-trigger"
     type="button"                         x-bind:data-popup-open="open ? &#039;&#039; : undefined" x-ref="trigger" aria-haspopup="dialog" x-on:click="toggle()" :aria-expanded="open" aria-label="Choose emoji" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control p-0">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="12" cy="12" r="10"/>
  <path d="M8 14s1.5 2 4 2 4-2 4-2"/>
  <line x1="9" x2="9.01" y1="9" y2="9"/>
  <line x1="15" x2="15.01" y1="9" y2="9"/>
</svg></button>
    <template x-teleport="body">
    <div data-slot="popover-content" x-bind="popup" x-init="popupEl = $el" x-nq-presence="open" x-anchor.bottom-start.offset.6="$refs.trigger"
        class="z-50 max-w-[var(--available-width)] rounded-floating border border-border bg-popover text-body-sm text-popover-foreground shadow-floating outline-none transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0 w-auto overflow-hidden p-0"><div data-slot="emoji-picker" x-data="nqEmojiPicker('none', 8)" x-on:keydown="onKey($event)"
    x-on:emoji-select="close(); text += $event.detail.emoji" class="isolate flex h-80 w-72 flex-col bg-popover text-popover-foreground">
    <div class="flex items-center gap-2 border-b border-border p-2">
        <div class="relative min-w-0 flex-1">
            <svg data-slot="icon" aria-hidden="true" class="pointer-events-none absolute inset-y-0 start-2.5 my-auto size-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m21 21-4.34-4.34"/>
  <circle cx="11" cy="11" r="8"/>
</svg>            <input x-model="query" type="search" data-slot="emoji-picker-search" aria-label="Search emoji" placeholder="Search emoji"
                class="h-control-sm w-full min-w-0 rounded-control border border-input bg-card ps-8 pe-2 text-body-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus">
        </div>
        <button type="button" data-slot="emoji-picker-skin-tone" aria-label="Change skin tone" x-on:click="cycleTone()" x-text="toneGlyph"
            class="inline-flex size-control-sm shrink-0 items-center justify-center rounded-control text-body outline-none transition-colors duration-150 ease-nq hover:bg-nq-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">✋</button>
    </div>
    <div data-slot="emoji-picker-viewport" class="relative flex-1 overflow-y-auto outline-none">
        <div x-show="! data" role="status" class="absolute inset-0 flex items-center justify-center text-body-sm text-muted-foreground">Loading emoji…</div>
        <div x-show="data &amp;&amp; ! count" style="display:none" role="status" class="absolute inset-0 flex items-center justify-center px-4 text-center text-body-sm text-muted-foreground"
            x-text="`No emoji found for “:q”.`.replace(`:q`, query)"></div>
        <div x-show="data &amp;&amp; count > 0" style="display:none" role="grid" class="select-none pb-1">
            <template x-for="s in sections" x-bind:key="s.index">
                <section>
                    <div data-slot="emoji-picker-category" class="sticky top-0 bg-popover px-3 py-1.5 text-start text-caption font-medium text-muted-foreground" x-text="s.label"></div>
                    <div data-slot="emoji-picker-row" class="grid scroll-my-1 px-1.5" style="grid-template-columns: repeat(8, minmax(0, 1fr)); justify-items: center">
                        <template x-for="item in s.items" x-bind:key="item.emoji.emoji">
                            <button type="button" data-slot="emoji-picker-emoji" role="gridcell"
                                x-bind:aria-label="item.emoji.label" x-bind:data-index="item.flat" x-bind:data-active="item.flat === active ? '' : null"
                                x-bind:tabindex="item.flat === active ? 0 : -1" x-on:click="pick(item.emoji)" x-on:focus="active = item.flat" x-text="glyph(item.emoji)"
                                class="flex size-8 items-center justify-center rounded-control text-[20px] leading-none outline-none hover:bg-nq-hover data-[active]:bg-nq-hover focus-visible:outline-2 focus-visible:outline-nq-focus"></button>
                        </template>
                    </div>
                </section>
            </template>
        </div>
    </div>
</div></div>
</template>
</div>
</div>
```
