# dash-emoji-mart — emoji picker for Dash — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> dash-emoji-mart — an emoji picker for Plotly Dash 4. Wraps emoji-mart as a single Dash component: search, six skin tones, a frequently-used row, 22 locales, custom image/GIF/SVG categories and any of Iconify's 150+ icon sets. The emoji data set ships inside the bundle, so the picker works offline and makes no requests of its own. By Pip Install Python.

Generated from 10 pages. The page index lives at https://emojimart.2plot.dev/llms.txt and a compact briefing at https://emojimart.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://emojimart.2plot.dev/llms.txt -->

### Overview

`dash-emoji-mart` wraps [emoji-mart](https://github.com/missive/emoji-mart) — the picker
behind Missive, and the one most React apps reach for — as a single Dash component.

One import, one component, no clientside setup:

```python
from dash import Dash, callback, html, Input, Output
from dash_emoji_mart import DashEmojiMart

app = Dash(__name__)
app.layout = html.Div([
    DashEmojiMart(id="picker"),
    html.Div(id="out", style={"fontSize": 48}),
])

@callback(Output("out", "children"), Input("picker", "value"))
def show(value):
    return value or "Pick one"

if __name__ == "__main__":
    app.run(debug=True)
```

The full emoji data set is bundled inside the component, so the picker works offline and
makes no requests of its own.

### Install

```bash
pip install dash-emoji-mart
```

Iconify icon-set support is an optional extra — see [Iconify icon sets](/iconify):

```bash
pip install "dash-emoji-mart[iconify]"
```

### Live demo


### What you get


    Everything emoji-mart does out of the box: fuzzy search across names, keywords and
    shortcodes, six skin tones, a per-browser "frequently used" row, and 10 UI locales.


    Add categories of your own images, GIFs or SVGs with `custom=`, and give each one a
    nav icon with `categoryIcons=`. See [Custom emojis](/custom-emojis).


    `dash_emoji_mart.iconify` converts any Iconify set — twemoji, openmoji, Fluent Emoji,
    or a non-emoji set like Tabler — into pickable categories. See
    [Iconify icon sets](/iconify).

### Where to go next

* [Configuration](/configuration) — every layout and sizing prop, live.
* [Theming](/theming) — light, dark and following the app's colour scheme.
* [Callbacks & props](/callbacks) — `value` vs `selectedEmoji`, outside clicks, persistence.
* [Picker in a popover](/popover) — the pattern most apps actually want.
* [API reference](/api-reference) — the full prop table.

### Source


```python
# File: docs/home/example.py

"""The smallest useful DashEmojiMart: a picker and a readout of what was picked.

`value` is a plain string — the native glyph for a standard emoji. `selectedEmoji`
carries the whole emoji-mart object alongside it, which is where the name and shortcode
below come from.
"""

import dash_mantine_components as dmc
from dash import Input, Output, callback, html

from dash_emoji_mart import DashEmojiMart

component = dmc.Group(
    [
        DashEmojiMart(
            id="home-picker",
            perLine=9,
            emojiSize=24,
            maxFrequentRows=2,
            previewPosition="none",
        ),
        dmc.Paper(
            [
                dmc.Text("Selected", size="sm", c="dimmed"),
                html.Div(
                    id="home-picker-glyph",
                    style={"fontSize": 56, "minHeight": 70, "lineHeight": 1.2},
                ),
                dmc.Text(id="home-picker-name", size="sm", fw=500),
                dmc.Text(id="home-picker-shortcode", size="xs", c="dimmed"),
            ],
            withBorder=True,
            p="lg",
            radius="md",
            style={"minWidth": 200, "textAlign": "center"},
        ),
    ],
    align="flex-start",
    gap="xl",
)


@callback(
    Output("home-picker-glyph", "children"),
    Output("home-picker-name", "children"),
    Output("home-picker-shortcode", "children"),
    Input("home-picker", "selectedEmoji"),
)
def show(emoji):
    if not emoji:
        return dmc.Text("Nothing yet", c="dimmed", size="lg"), "", ""
    shortcodes = emoji.get("shortcodes") or ""
    return emoji.get("native", ""), emoji.get("name", ""), shortcodes
```

---

<!-- /api — https://emojimart.2plot.dev/api/llms.txt -->

# API reference

## dash_emoji_mart

### DashEmojiMart

DashEmojiMart wraps the emoji-mart picker (https://github.com/missive/emoji-mart)
as a Dash component.

Selection is reported through two props, and callbacks may use either:

  * `value` — a plain string. `emoji.native` for a standard emoji ("😀"), or
    `emoji.src` for a custom one (the image URL). This is the 0.0.x contract
    and is unchanged.
  * `selectedEmoji` — the full emoji-mart object (id, name, native, unified,
    shortcodes, keywords, skin, src, ...), for callbacks that need more than
    the glyph. Added in 0.2.0.

Both are written in a single `setProps` call, so a callback with both as
Inputs fires once per pick rather than twice.

| prop | type | default | description |
|---|---|---|---|
| `id` | string | object |  | The ID used to identify this component in Dash callbacks. |
| `autoFocus` | bool | false | Focus the search input when the picker mounts. |
| `categories` | array | [] | Which categories to show, in order. Empty (the default) shows all of them. e.g. `["frequent", "people", "nature"]`. |
| `categoryIcons` | object | {} | Icons for custom categories, keyed by category id. The value is inlined onto the matching entry in `custom` — typically an inline SVG string. |
| `className` | string |  | CSS class applied to the wrapper element around the picker. |
| `clickedOutside` | number | 0 | Incremented once each time the user clicks outside the picker. Use it the way you would use `n_clicks` — for example to close a popover.  "Outside" means outside the component's wrapper element, so clicking an emoji, the search box or a category tab does NOT increment it. The click that opens the picker does not increment it either. Added in 0.2.0. |
| `custom` | array | [] | Custom emoji categories. Each entry is `{id, name, emojis: [{id, name, keywords, skins: [{src}]}]}`. |
| `dynamicWidth` | bool | false | Let the picker's width follow its container instead of `perLine`. |
| `emojiButtonColors` | array | [] | Background colours cycled through on emoji hover/focus. |
| `emojiButtonRadius` | string | '100%' | Border radius of each emoji button. Default `"100%"`. |
| `emojiButtonSize` | number | 36 | Size in px of each emoji button. Default 36. |
| `emojiSize` | number | 24 | Size in px of the emoji inside its button. Default 24. |
| `emojiVersion` | number | 14 | Maximum Emoji version to show. Default 14. |
| `exceptEmojis` | array | [] | Emoji ids to hide from the grid, e.g. `["rage", "cry"]`.  GRID ONLY — searching still finds them, for the same reason as `noCountryFlags`: both filters remove the emoji from its category while `SearchIndex.search` reads the unfiltered emoji map. Do not rely on this to keep a specific emoji away from a user. |
| `icons` | string | 'auto' | Category/search icon style: `"auto"`, `"outline"` or `"solid"`. |
| `locale` | string | 'en' | UI locale, e.g. `"en"`, `"fr"`, `"de"`, `"ja"`. |
| `maxFrequentRows` | number | 4 | Rows reserved for frequently used emojis. Default 4. |
| `navPosition` | string | 'top' | Category nav position: `"top"`, `"bottom"` or `"none"`. |
| `noCountryFlags` | bool | false | Hide country flags from the grid. Default False.  GRID ONLY — searching still finds them. This is an emoji-mart limitation, measured against 5.6.0: the filter runs while building each category and removes the emoji from `category.emojis`, but `SearchIndex.search` matches over `Object.values(Data.emojis)`, the unfiltered map, and applies no category filter of its own. So with this on, the flags category shrinks to a small safe list while typing "united" still returns the flags of the UK, US, UAE and the UN.  Not worked around here on purpose. emoji-mart loads its data into a module-global exactly once per page, so pre-filtering the data for one picker would silently change every other picker on the page and every page after it in a Dash SPA. A visible search result is better than an invisible, mount-order-dependent one. |
| `noResultsEmoji` | string | 'cry' | Emoji id shown when a search returns nothing. Default `"cry"`. |
| `perLine` | number | 9 | Emojis per row. Default 9. |
| `persisted_props` | list of one of 'value', 'selectedEmoji' | ['value'] | Properties whose value is persisted. Defaults to `["value"]`. |
| `persistence` | bool | string | number |  | Whether the picker's selection is persisted across browser sessions. |
| `persistence_type` | one of 'local', 'session', 'memory' | 'local' | Where persisted selections are stored: `"local"`, `"session"` or `"memory"`. |
| `previewEmoji` | string | 'point_up' | Emoji id shown in the idle preview. Default `"point_up"`. |
| `previewPosition` | string | 'bottom' | Preview position: `"top"`, `"bottom"` or `"none"`. |
| `searchPosition` | string | 'sticky' | Search bar position: `"sticky"`, `"static"` or `"none"`. |
| `selectedEmoji` | object |  | The full emoji-mart object for the current selection — id, name, native, unified, shortcodes, keywords, skin and (for custom emojis) src. Set alongside `value` on every pick. Read-only. Added in 0.2.0. |
| `set` | string | 'native' | Emoji set: `"native"`, `"apple"`, `"facebook"`, `"google"` or `"twitter"`. Default `"native"`. |
| `skin` | number | 1 | Default skin tone, 1 (lightest) to 6 (darkest). Default 1. |
| `skinTonePosition` | string | 'preview' | Skin-tone selector position: `"preview"`, `"search"` or `"none"`. |
| `style` | object |  | Inline styles applied to the wrapper element around the picker. |
| `theme` | string | 'auto' | Colour scheme: `"auto"`, `"light"` or `"dark"`. |
| `value` | string |  | The selected emoji as a string: the native glyph for a standard emoji ("😀"), or the image URL for a custom one. Read this in callbacks. |

---

<!-- /api-reference — https://emojimart.2plot.dev/api-reference/llms.txt -->

# API Reference

> Every prop on DashEmojiMart, generated from the component itself.

---



### DashEmojiMart

The package exposes exactly one component. The table below is read from the installed
component at page load, so it always matches the version you have.


### Selection props

`value` and `selectedEmoji` are written together on every pick; `clickedOutside` is a
counter. All three are outputs of the component — setting them from Python sets the
picker's idea of the current selection, but does not move the UI.

See [Callbacks & props](/callbacks) for what each one contains.

### Removed in 0.2.0

Four props existed in 0.0.x that no Dash app could ever use — Dash serialises props to
JSON, and a Python function is not serialisable, so passing any of them raised. They were
always `None` in practice:

| Removed prop | Replacement |
|--------------|-------------|
| `onEmojiSelect` | `Input("picker", "value")` or `Input("picker", "selectedEmoji")` |
| `onClickOutside` | `Input("picker", "clickedOutside")` |
| `onAddCustomEmoji` | no equivalent; open an issue if you need one |
| `getSpritesheetURL` | no equivalent; `set` covers the built-in spritesheets |

### Version


<!-- component rendered from docs/api-reference/example.py; source withheld by `:code: false` -->


---

*Source: /api-reference*

---

<!-- /callbacks — https://emojimart.2plot.dev/callbacks/llms.txt -->

# Callbacks & Props

> value vs selectedEmoji, reacting to outside clicks, and persisting a selection across reloads.

---



### Two ways to read a selection

Every pick writes **both** props in one update, so a callback with both as `Input`s fires
once, not twice.

| Prop | Type | Contents |
|------|------|----------|
| `value` | `str` | `emoji.native` for a built-in emoji (`"😀"`), or `emoji.src` — the image URL — for a custom one. |
| `selectedEmoji` | `dict` | The whole emoji-mart object. |

`value` is the 0.0.x contract and is unchanged, so existing callbacks keep working.
`selectedEmoji` was added in 0.2.0 for callbacks that need more than the glyph:

```python
{
    "id": "grinning",
    "name": "Grinning Face",
    "native": "😀",
    "unified": "1f600",
    "keywords": ["face", "smile", "happy", "joy", "grin"],
    "shortcodes": ":grinning:",
    "skin": 1,
    "aliases": ["grinning_face"],
}
```

A custom or Iconify emoji carries `src` instead of `native` — which is the cleanest way to
tell the two apart:

```python
@callback(Output("out", "children"), Input("picker", "selectedEmoji"))
def show(emoji):
    if not emoji:
        return "Nothing picked"
    if emoji.get("src"):
        return html.Img(src=emoji["src"], style={"height": 32})
    return emoji["native"]
```

### Live demo


### Reacting to a click outside

emoji-mart hands its `onClickOutside` handler a DOM event, which cannot cross into Python.
The component surfaces it as `clickedOutside`, a counter with the same shape as
`n_clicks` — it increments on each outside click:

```python
@callback(
    Output("popover", "opened", allow_duplicate=True),
    Input("picker", "clickedOutside"),
    prevent_initial_call=True,
)
def close(_n):
    return False
```


    Only the *change* matters; the number itself is meaningless. Pair it with
    `prevent_initial_call=True` so a fresh mount does not fire the callback.


    `dmc.Popover` already dismisses itself on an outside click and writes `opened=False`
    back to Dash, so `clickedOutside` earns its keep on panels you built yourself — a
    `dmc.Collapse`, a styled `html.Div`, an absolutely-positioned card. See
    [Picker in a Popover](/popover) for what the popover case does and does not need.


    Clicking an emoji, the search box or a category tab does **not** increment it, and
    neither does the click that opened the picker.

    Both are worth stating because emoji-mart's own `onClickOutside` does the opposite on
    both counts: it fires whenever the event target is not exactly the picker's root
    element — so every emoji click counts as "outside" — and it registers its listener
    during mount, while the opening click is still bubbling, so it reports an outside
    click before the user has clicked anything. Wired to a popover, that closed the
    popover on the same click that opened it. `clickedOutside` is measured against the
    component's wrapper and bound one task after mount, so neither happens.

### Persistence

`value` is persisted by default once `persistence` is switched on, using Dash's standard
persistence machinery:

```python
DashEmojiMart(
    id="picker",
    persistence=True,
    persisted_props=["value", "selectedEmoji"],
    persistence_type="local",   # "local" | "session" | "memory"
)
```

`persisted_props` defaults to `["value"]` and `persistence_type` to `"local"`.


    emoji-mart keeps its own frequently-used tally in `localStorage` under its own key,
    independently of Dash persistence. It survives a reload whether or not `persistence`
    is set; `maxFrequentRows=0` is what turns it off.

### Pattern-matching ids

`id` accepts a dict, so pickers work inside `ALL`/`MATCH` callbacks like any other
component:

```python
DashEmojiMart(id={"type": "emoji-picker", "index": row_id})

@callback(
    Output({"type": "emoji-out", "index": MATCH}, "children"),
    Input({"type": "emoji-picker", "index": MATCH}, "value"),
)
def show(value):
    return value
```

### Props that need a remount

Most props update in place. Three are read only when emoji-mart builds its internal store,
so changing them on a mounted picker has no visible effect:

| Prop | Why |
|------|-----|
| `set` | Selects the spritesheet, loaded at init. |
| `locale` | Builds the translated search index at init. |
| `custom` | Merged into the emoji store at init. |

Force a remount by varying the **id** of a wrapper:

```python
return html.Div(DashEmojiMart(id="picker", set=chosen_set), id=f"mount-{chosen_set}")
```

Dash's renderer keys each child on its id, so a new wrapper id is a new React key and the
subtree is thrown away rather than patched. A `key=` prop does not work for this — it is
accepted by html components but never read during reconciliation, and it surfaces in the
console as `` `key` is not a prop ``.


    emoji-mart's store is module-global, not per-component, and `categories` is filtered
    against a snapshot taken on the *first* initialisation in the page's lifetime. A
    remount runs a *later* initialisation, which is exactly the case where custom ids no
    longer resolve — so remounting does not rescue the combination, it triggers the
    failure. Omit `categories` when you pass `custom`. See
    [Custom emojis](/custom-emojis#ordering-custom-categories).

### Source


```python
# File: docs/callbacks/example.py

"""`value`, `selectedEmoji` and `clickedOutside` shown side by side, live.

Both selection props are Inputs to one callback on purpose: the component writes them in a
single `setProps`, so this fires once per pick rather than twice.
"""

import json

import dash_mantine_components as dmc
from dash import Input, Output, callback, html

from dash_emoji_mart import DashEmojiMart

component = dmc.Group(
    [
        DashEmojiMart(
            id="cb-picker",
            perLine=8,
            emojiSize=22,
            maxFrequentRows=1,
            # Persist the pick across reloads. Reload the page after choosing
            # one — the readout comes back with it.
            persistence=True,
            persisted_props=["value", "selectedEmoji"],
            persistence_type="local",
        ),
        dmc.Stack(
            [
                dmc.Paper(
                    [
                        dmc.Group(
                            [
                                dmc.Text("value", size="sm", fw=600),
                                dmc.Badge("str", variant="light", size="sm"),
                            ],
                            gap="xs",
                        ),
                        dmc.Code(id="cb-value", children="None"),
                    ],
                    withBorder=True,
                    p="md",
                    radius="md",
                ),
                dmc.Paper(
                    [
                        dmc.Group(
                            [
                                dmc.Text("selectedEmoji", size="sm", fw=600),
                                dmc.Badge("dict", variant="light", size="sm"),
                            ],
                            gap="xs",
                        ),
                        dmc.CodeHighlight(
                            id="cb-object", code="None", language="json"
                        ),
                    ],
                    withBorder=True,
                    p="md",
                    radius="md",
                ),
                dmc.Paper(
                    [
                        dmc.Group(
                            [
                                dmc.Text("clickedOutside", size="sm", fw=600),
                                dmc.Badge("int", variant="light", size="sm"),
                            ],
                            gap="xs",
                        ),
                        dmc.Text(
                            id="cb-outside",
                            children="0 — click anywhere off the picker",
                            size="sm",
                            c="dimmed",
                        ),
                    ],
                    withBorder=True,
                    p="md",
                    radius="md",
                ),
            ],
            gap="md",
            style={"flex": 1, "minWidth": 320},
        ),
    ],
    align="flex-start",
    gap="xl",
)


@callback(
    Output("cb-value", "children"),
    Output("cb-object", "code"),
    Input("cb-picker", "value"),
    Input("cb-picker", "selectedEmoji"),
)
def show(value, emoji_obj):
    if not value:
        return "None", "None"
    return repr(value), json.dumps(emoji_obj, indent=2, ensure_ascii=False)


@callback(
    Output("cb-outside", "children"),
    Input("cb-picker", "clickedOutside"),
    prevent_initial_call=True,
)
def outside(n):
    return f"{n} — the counter increments, the number itself is not meaningful"
```


---

*Source: /callbacks*

---

<!-- /changelog — https://emojimart.2plot.dev/changelog/llms.txt -->

# Changelog

> Version history of dash-emoji-mart. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to this project are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] — the docs site

Nothing here changes `pip install dash-emoji-mart`; this is the site.
Consumed template SYNC-1.6.22-1.6.35 items 12 and 13 (template `4c63992`),
SYNC-1.6.22-1.6.38 items 15 and 16 (template `519d496`), and
SYNC-1.6.22-1.6.40 item 17 (template `8ceca5c`).

### Fixed — the verification tools were reading the wrong document

- **Every live tool's default User-Agent now names the browser lane.** At
  dash-improve-my-llms ≥ 2.8 a User-Agent with no browser engine token is
  crawler-lane, so `scripts/network_smoke.py`'s bare internal token — and
  `scripts/verify_network.py`'s *absent* UA — made every default-UA check read
  the prerendered crawler document instead of the page a person sees. The
  internal token stays in the string, after the engine token, so these probes
  still never count as real traffic. `CRAWLER_UA` is untouched: it is the
  other lane, and collapsing the two would make every crawler assertion
  vacuous.
- **`verify_network.py`'s "prerender marker present" check had been red on
  `main`** for an unknown period, and nothing said so — that script is
  hand-run and wired into neither CI nor CD. Measured here:
  `data-dimll-prerender="1"` is on the *browser* document and the crawler lane
  never carries it, so a check reading the crawler document could only fail.
- **Two stale assertions in the same tool**, found once it ran clean enough to
  see them: `/healthz` was compared against a frozen three-key dict while the
  payload had grown `python` and `geo`, and the sitemap count was the literal
  `8` that `/changelog` and `/api` turned into 10. Both now assert the
  contract — the promised keys, and a count derived from the page registry.

### Added — navigation

- **`/changelog`** — this file as a Timeline, and its own `LLMS_DOC`. Linked
  once, from the top of the sidebar.
- **`/api`** — a generated prop table per exported component, read from
  `dash_emoji_mart`'s `metadata.json` (Dash 4's generated classes carry no
  `_prop_names`). 34 props on `DashEmojiMart`. The same tables are the page's
  `LLMS_DOC`.
- **A footer** — © the computed year, the GitHub *profile*, the 2plot Discord
  and the YouTube subscribe link. Every icon has an accessible name.
- **An *Other Apps* menu** in the top bar, built from
  `lib/network_directory`'s `PRIMARY` set with this host removed: 2plot.ai,
  2plot.dev, 2plot.media, piratesbargain.com, ai-agent.buzz. It is the only
  place the network is listed now.
- **A backend badge and a version badge** in the top bar (`v0.2.1`, read from
  the documented package).
- **An Admin section** in the sidebar, filled by a callback and empty for
  everyone else. `/admin/control-board` and `/admin/traffic` used to sit in a
  public "Other" bucket, shown to every visitor and correctly refused — the
  startup tree now carries no `/admin/` href at all, and neither does search.

### Changed — navigation

- **The sidebar is built from frontmatter.** Every docs page declares
  `category:` and `order:`; `components/navbar.py` holds no fork content and
  no page list. Section order comes from `CATEGORY_ORDER` in
  `lib/constants.py`: Start here · Configuration · Custom emojis · Dash
  integration — the names this site already used.
- **Resources is third-party only**: `dmc`, emoji-mart and Iconify sets. The
  GitHub, Discord and "Dash Community" links are gone from it — the repo is
  the top bar's icon and the community links are the footer's, and each was
  being listed twice. The Discord link now points at the 2plot server; it
  pointed at a different one.
- **`.. source::` code blocks name their copy button** (`copyLabel` /
  `copiedLabel`), the Burger and the home link have accessible names, and
  headings containing inline code no longer take the page down at import.
- **Code blocks, prop tables and inline code stay inside the viewport** at
  phone width. `.. props::` tables were never matched by the rule that
  claimed to cover them.
- **`/admin/traffic`**: the day picker is a `dmc.DatePickerInput` with
  presets, not a `dcc.Dropdown`, and the human numbers have their own
  **People** section above the crawler tables — humans never enter the read
  ledger, so "(unidentified)" below is the UA-less crawler lane, never a
  person.
- **The right-hand TOC column collapses** on pages that render no `.. toc::`.

### Changed — crawler posture

- **The AI-training wall is retired.** `robots.txt` no longer Disallows
  GPTBot / ClaudeBot / CCBot and the package stops answering 403 to them on
  the browser document and `/healthz`. The wall decided by vendor *class*
  what nobody could account for; every corpus read is now a ledger row with
  tier, vendor, verification and bytes, so the tool from here is per-vendor
  policy for one vendor whose rows justify it. Measured in-process: both UAs
  get 200 on `/`, `/llms.txt` and `/healthz`, and `robots.txt` carries no
  `Disallow` at all.

### Changed

- **`human_hits` DROPS and `bot_hits` RISES from the day this deploys, and
  that is the number becoming true, not a regression.** `lib/analytics_tracker`
  carried its own User-Agent lists for a year. They filed ClaudeBot —
  Anthropic's *training* crawler — under "search"; they still named the
  retired `anthropic-ai` / `claude-web` tokens; and they counted every UA-less
  or library client (`httpx`, `Go-http-client`, `node-fetch`, an empty
  User-Agent) as a desktop human. Those clients now land on the crawler lane,
  where they always belonged. Expect a visible step in the hub's day-over-day
  view for this app on the adoption date; nothing before it was re-scored.
- **There is ONE classifier: `dash_improve_my_llms.classify()`.** `is_bot` and
  `detect_bot_type` keep their names and signatures for callers and delegate;
  the module ends with zero User-Agent strings, and
  `tests/test_analytics_classifier.py` greps it for the old tokens. A token the
  package's registry lacks is a pushback to the package, never a list here.
- **The dash-improve-my-llms floor moves 2.7.1 → 2.8.0**, in all five
  encodings (`requirements.txt`, `run.py`'s `_DIMLL_FLOOR` and its boot
  diagnosis, `tests/test_site_identity.py`, and both `ci.yml` asserts).
- **Render deploys `release`, and only CD writes `release`.** A push to `main`
  is a candidate, not a deploy: `cd.yml`'s `deploy` job fast-forward-pushes the
  run's own sha to `release` after the CI matrix is green, and Render's
  autoDeploy reacts to that branch. `main` ahead of `release` means an
  uncertified push is pending. `verify` now runs only on a *successful* deploy
  and asserts `/healthz build == github.sha` itself — the old
  `always() && != 'cancelled' && != 'skipped'` admitted `failure`, and a verify
  that passes when nothing deployed must not exist.

### Added

- **The ledger's second table, `reads`.** dash-improve-my-llms 2.8.0 emits one
  event per corpus document it serves (`on_document_read`); `run.py` registers
  it once into `AnalyticsTracker.record_read`, which keeps every
  `_ledger.EVENT_FIELDS` key in a `reads` list in the same analytics file —
  same buffer, lock, flush cadence and retention as `visits`, with `client_ip`
  dropped unless `ANALYTICS_KEEP_CLIENT_IP=1`. It is a second table JOINED by
  the rollup, never summed into `human_hits` / `bot_hits` / `pages`.
- **Rollup v4, additive.** On a day with reads the payload gains
  `vendors[{key, class, verified, policy, hits, bytes, tiers{…}}]` (one row per
  `(key, verified, policy)`, the null key kept as the unverifiable bulk, capped
  at 40) and `reads` = `sum(vendors[].hits)`. Every v3 key is byte-identical,
  and a reads-only day is reported.
- **`/admin/traffic`** (`pages/traffic.py`) — this host's own ledger behind the
  control board's exact gate, failing closed without Clerk: vendor × day,
  vendor → tier for the picked day, top paths per vendor, and the v3 headline
  numbers alongside. Plain tables, no charts, no interval callback. `verified`
  is `n/a` where the operator publishes no IP ranges — Anthropic publishes
  none, so ClaudeBot is *always* `n/a` here, and that is a property of the
  vendor, not a defect on this host.
- **A declared posture fence** in `DIVERGENCES.md`, measured on the wire
  2026-08-29: `ai_bots {"/": 403, "/llms.txt": 200, "/healthz": 403}`,
  `healthz: full`, `runtime: docker`, `deploy: release-branch`.

## [0.2.1] — 2026-08-01

Two things: a picker bug that made whole custom categories invisible, and this
site joining the 2plot network properly. Only the first affects
`pip install dash-emoji-mart`.

**This is the first 0.2.x anyone can install.** 0.2.0 below was written, dated
and never shipped — no `v0.2.0` tag was ever pushed and nothing was uploaded, so
the newest thing `pip install dash-emoji-mart` has ever returned is still
`0.0.3` from 2024. Read the two sections together: 0.2.0 is what changed in the
repository, 0.2.1 is what changed since, and a single `v0.2.1` tag publishes
both at once.

### Fixed — the component

- **Custom emojis whose `src` is a dimensionless SVG rendered as nothing.**
  emoji-mart sizes a custom emoji's image with `max-width`/`max-height` and
  nothing else. A raster source has intrinsic dimensions and scales down to
  fit; an SVG carrying only a `viewBox` has no intrinsic size, so `width: auto`
  under a bare `max-width` resolves to zero. The emoji was in the DOM,
  focusable and clickable — and 0×0.

  It looked arbitrary rather than systematic, because it depended only on what
  the author happened to link. Iconify's API SVGs ship `width="1em"` and were
  always fine; SVGs straight off a repo were not. On the docs site's own
  custom-emoji demo that meant one category rendered and the next two were
  blank strips under their headings.

  The component now adopts a stylesheet into the picker's shadow root giving
  custom-emoji images `width: 1em; height: 1em; object-fit: contain`. `1em` is
  the size emoji-mart already intends — it sets font-size to `emojiSize` on
  each grid button and to the larger preview size in the footer — so one rule
  is right in both places, non-square artwork is not distorted, and raster
  sources render exactly as before.

- **`theme="auto"` follows the APP, then the OS — in that order.** emoji-mart's
  "auto" reads `prefers-color-scheme` and nothing else, which is the wrong
  signal in a Dash app: almost every one ships a theme toggle, and a toggle
  does not touch the OS. On a machine set to dark, flipping a Dash Mantine app
  to light left every picker dark against a white page — and it looked like the
  component ignoring its own `theme` prop.

  The component now prefers `data-mantine-color-scheme` on `<html>` when the
  document advertises one, and falls back to the media query otherwise. Reading
  an attribute adds no dependency on DMC: an app that does not set it behaves
  exactly as before. An explicit `theme="light"`/`"dark"` is still passed
  straight through, and the resolution re-runs on a toggle without a remount.

  This is why the docs site had the bug on six of its pages: only
  `/theming` wired the clientside callback its own prose recommends. That
  callback still works and is still the right answer for a non-Mantine toggle —
  it is just no longer needed for the common case.

- **`dynamicWidth` made the picker collapse instead of fill.** emoji-mart
  implements the option by setting `width: 100%` on a `<section>` inside its
  shadow root and never touches the `<em-emoji-picker>` host. A custom element
  has no author width, so it sizes to its content — and its content is that
  section asking for 100% of the host. The constraint is circular and resolves
  at min-content: measured, turning the switch on took the picker from 532px to
  **216px** inside a 482px parent, and the grid reflowed under the pointer,
  which reads as the component glitching on hover.

  The component now sizes the host itself when `dynamicWidth` is on, and clears
  the width when it is off. It has to happen outside the shadow root: the host
  is in the light DOM, so no shadow stylesheet reaches it, and `className` /
  `style` apply to our wrapper rather than to it. Measured after: 352px → 482px
  (the full container), 9 → 13 emoji per row, and it reverts cleanly.

  The container still needs a width of its own for "100%" to mean anything —
  a shrink-to-fit flex item gives 100% of nothing — so
  `docs/configuration/example.py` now gives the mount `flex: 1; minWidth: 0`
  and says why.

### Fixed — the documentation site

- **The popover example never opened.** `dmc.Popover` toggles `opened` itself
  when its target is clicked, so the example's extra `toggle` callback read the
  already-flipped value as `State` and returned `not True` — closing the
  popover in the same round trip that opened it. The symptom was a trigger that
  appeared to do nothing. Down to one callback, and the page now documents the
  trap rather than demonstrating it.
- **Inline `**bold**` and `*italic*` put a `<p>` inside a `<p>`.**
  markdown2dash renders paragraphs and inline emphasis with the same
  `dmc.Text`. `lib/markdown_inline.py` rebinds the three inline formatters to
  `span=True`. Five invalid nestings across the site, now none.

### Added — a visual identity of its own

The site's mark was `noto:grinning-face-with-smiling-eyes` — the generic yellow
smiley, which is every emoji library's placeholder and named nothing about this
one. It is now **🤠 U+1F920**, and the same drawing reaches every surface.

- **Header, favicon and share card are one glyph.** `assets/brand/cowboy-hat-face.png`
  (Noto Emoji, Apache-2.0) is committed as the single source; the header renders
  `noto:cowboy-hat-face` through Iconify, so the tab icon, the navigation mark
  and an unfurl are the same drawing rather than three vendors' idea of it.
- **`scripts/make_brand_assets.py`** derives the favicon set and the web app
  manifest from that source: `favicon.ico` (16/32/48), 192 and 512 PNGs, an
  apple-touch-icon flattened onto the dark surface because iOS backs a
  transparent icon with white, and `site.webmanifest` whose name, short name
  and theme colour are read from `lib/constants.py` rather than retyped.
  `--check` re-derives and compares, so a hand-edit or a stale run fails.
- **The manifest tests are no longer stubbed.** 0.2.1 shipped this file with a
  block explaining that the donor's seven installable-app assertions had no
  surface to test here. They do now: the manifest is linked and served, names
  this site, is installable, every icon it declares resolves, the
  apple-touch-icon resolves and is opaque, and the theme colour agrees. Suite
  is 92 → 99.
- **The social card exists.** 1200x630, the fleet layout — accent rule, brand,
  three-line tagline, mono domain — with the cowboy on the right, matching how
  `email.2plot.dev` frames its artwork. `make_social_card.py` now WARNS when a
  tagline overflows its three-line budget instead of silently slicing it; the
  first render of this card read "...custom image and SVG categories, and" and
  stopped mid-clause, which is precisely the kind of thing nobody catches on an
  asset they never see.
- **`github_assets/` is gone.** The 2.9 MB README demo GIF now comes from
  `cdn.2plot.ai/github_assets/github-demo.gif`, so cloning the repo no longer
  drags it along.

### Changed — the 2plot network standard

Brings this host onto the standard proven on 2plot.ai, 2plot.dev, boilerplate,
leaflet, email, flexlayout and llms. Nothing here changes the package.

- **One brand, every surface.** `SITE_BRAND = "dash-emoji-mart — emoji picker
  for Dash"` now reaches `Dash(title=)`, the `/llms.txt` H1 and viewer chip
  (via dimll 2.3.4's `resolve_site_title`), the template's fallback `<title>`,
  the README and the home page's own heading. The package name leads, because
  for a component library the package is what a reader came to find; "Pip
  Install Python" is the byline and lives in the description.
- **One short app id.** `AD_APP_ID` defaulted to `dash-emoji-mart` — the PyPI
  name — while the traffic reporter already said `emojimart`, so the same app
  would have reached the hub under two ids and its ad rows would never have
  lined up with its traffic rows. Everything now folds to `lib.constants.APP_KEY`.
- **The internal-traffic contract, both halves.** Requests carrying
  `2plot-internal` are dropped at WRITE time, before bot classification, so the
  hub's health sweep and CI batteries cannot land in `bot_hits`; `/healthz` is
  never a visit. Outbound, the ad client and the traffic reporter now identify
  themselves — the ad fetch runs once per page view and was arriving at
  2plot.dev as `python-requests`, which its tracker counts as a bot, so this
  site's readers were inflating the hub's crawler numbers.
- **The social card.** An OG block in `lib/constants.py`, `image_url=` and
  `description=` at every `register_page`, and the auxiliary tags Dash never
  emits in the template. One missing `image_url=` makes Dash emit
  `content=""`, and an empty tag late in the document beats a good one earlier.
- **`lib/bulletin.py`**, wired through `NETWORK_BULLETIN_URL` with a boot line
  that says which state the process is in — an unwired bulletin has no failure
  mode, only an announcement that never appears.
- **`gunicorn>=23`.** The floor was `>=21.2,<22`, a ceiling inherited wholesale
  from markdown2dash's dependency block, holding this image on a gunicorn
  affected by CVE-2024-6827 and CVE-2024-1135. markdown2dash now installs
  `--no-deps` at every install site and CI asserts the resolved version inside
  the built image.
- **dash-improve-my-llms `>=2.3.4`**, the floor that delivers `resolve_site_title`.
- **CI/CD.** `cd.yml` owns `main` and calls `ci.yml` via `uses:` so merges stop
  running everything twice; actionlint runs first; a container job builds the
  real image, boots it, asserts dependency fingerprints inside it and runs the
  network battery against it; the deploy waits for five consecutive `/healthz`
  successes rather than one, because Render swaps instances and the old one
  answers throughout.

### Measured, not fixed — three upstream findings

All verified on this host and recorded so the next pass does not rediscover
them.

- **Removing a category is one-way for the lifetime of the page.** Setting
  `maxFrequentRows` to `0` drops the Frequently-used section and setting it
  back does not restore it; picking a few `categories` and then clearing the
  field does not restore "all of them" either. Same cause: `init()` builds
  `Data.categories` once and mutates that array in place, `splice`-ing out any
  category that ends up empty, and the only path that rebuilds it from
  `Data.originalCategories` is the one that runs when `categories` is passed.
  The state is a module global that outlives the component, every remount and
  every SPA navigation; a page reload is the only reset. Documented on the
  configuration page.
- **`noCountryFlags` and `exceptEmojis` filter the grid, not the search**
  (emoji-mart 5.6.0). Both remove the emoji from `category.emojis` while
  `SearchIndex.search` matches over `Object.values(Data.emojis)` — the
  unfiltered map — with no category filter of its own. Measured: with
  `noCountryFlags` on, the flags category shrinks to the safe list and typing
  "united" still returns 🇬🇧 🇺🇸 🇦🇪 🇺🇳, identically to having it off.

  Not worked around, deliberately. emoji-mart loads its data into a
  module-global exactly once per page (`if (!Data) Data = props.data`), so
  pre-filtering the data for one picker would silently change every other
  picker on the page and every page after it in a Dash SPA — the same aliasing
  trap `categories` + `custom` already carries a red warning about. A visible
  search result beats an invisible, mount-order-dependent one. Documented on
  both props, in the README table, and on the configuration page; pinned by
  `tests/test_component.py` so a regeneration cannot quietly drop the caveat.

- **The prerender overwrites Dash's per-page `<title>` and appends its own
  `og:title`,** both from the page's registered `name`. `PAGE_TITLE_PREFIX`
  therefore never reaches `<title>` or the scraper-winning `og:title` on any
  page of any satellite — they read "Picker in a Popover", not
  "dash-emoji-mart | Picker in a Popover". Registering the prefixed string as
  `name` would fix the tags and wreck the `## Pages` index in `/llms.txt`,
  which lists that same value inside a document already headed by the brand.
  Left as-is and pinned by a test that fails when the behaviour changes.
- **`og:image` for crawler-classified UAs is still open (LESSONS §6), and the
  obvious fix is a trap.** 2.3.4's prerender *does* emit `og:image` when the
  page metadata carries an `og_image` key, and passing it measurably works —
  but Dash already emitted one, so the page ends up with two `og:image` tags
  and `scripts/smoke_live.py` requires exactly one. Duplicate `og:image` is how
  the fleet previously shipped an SVG card that beat a good one on tag order,
  so the rule is right and the per-site fix is wrong. Closing this needs the
  prerender to emit `og:image` only when the document does not already carry
  one. Separately, Slackbot and Twitterbot are served
  `html_generator.py`'s hard-coded head, which has no `og:image` and no hook
  for one at all.

## [0.2.0] — 2026-07-31 (never published)

**Not on PyPI.** This section was written and dated but no `v0.2.0` tag was
pushed, so nothing was ever built or uploaded under this number. It is kept as
the record of the restructure rather than rewritten into 0.2.1, because the
"On the version number" reasoning below is what explains the jump from `0.0.3`
and is still the reasoning. Everything described here ships as part of `0.2.1`.

The repository housekeeping release. 0.0.x was a `dash-component-boilerplate`
scaffold with a demo app bolted on; 0.2.0 is a packaged component with a hosted
documentation site, a compatibility matrix and a release pipeline.

### On the version number

**0.2.0 deliberately skips ahead of both existing lineages.** Two different
"current" versions have been in circulation:

- **PyPI** holds `0.0.1`, `0.0.2` and `0.0.3`. `0.0.3` (2024) is the newest
  thing `pip install dash-emoji-mart` has ever returned, and it raises on import
  under Dash 4 with React 18.2 — it predates both.
- **`0.0.5`** is the build that actually works, but it was never published. It
  exists only as a tarball vendored into sibling projects (`dash-leaflet2`
  installs it from `vendor/dash_emoji_mart-0.0.5.tar.gz`), so no PyPI user has
  ever been able to get it.

A `0.0.6` would have been ambiguous against both. `0.2.0` is unambiguous, and
the jump is a signal rather than a cost: this is a `0.x` project, and the gap
between `0.0.3` on PyPI and what this release contains is not a patch-level gap.
This is the first release where the PyPI artifact, the vendored tarball and the
repository all describe the same software.

Consumers vendoring `0.0.5` keep working — a local tarball install is unaffected
by anything published here — but should move to `dash-emoji-mart>=0.2.0` from
PyPI and drop the vendored copy. That vendoring only ever existed because PyPI's
`0.0.3` was broken.

### Added

- **`selectedEmoji`** — the full emoji-mart object for the current selection
  (`id`, `name`, `native`, `unified`, `shortcodes`, `keywords`, `skin`, and
  `src` for custom emojis). Written in the same `setProps` call as `value`, so a
  callback taking both fires once per pick rather than twice.
- **`clickedOutside`** — an `n_clicks`-style counter that increments each time
  the user clicks outside the picker. emoji-mart's own `onClickOutside` hands
  its handler a DOM event, which cannot cross into Python; this makes the signal
  usable for dismissing a hand-rolled panel. (Inside a `dmc.Popover` it is not
  needed — Mantine already closes on an outside click and writes `opened` back.)

  It is measured against the component's own wrapper rather than delegated to
  emoji-mart, because emoji-mart's version is wrong in two ways for this use.
  It fires whenever the event target is not *exactly* the picker's root element,
  so clicking an emoji counts as an outside click; and it registers its document
  listener during mount, while the click that opened the picker is still
  bubbling toward `document`, so it reports an outside click before the user has
  clicked anything. Wired to a popover, that closed the popover on the same
  click that opened it. This implementation containment-tests against the
  wrapper and binds one task after mount, so neither happens.
- **`className` and `style`** on the wrapper element, so the picker can be
  positioned and themed (emoji-mart's `--em-rgb-*` custom properties) without a
  wrapping `html.Div`.
- **Dash persistence** — `persistence`, `persisted_props` (default `["value"]`)
  and `persistence_type` (default `"local"`).
- **`dash_emoji_mart.iconify`** — the Iconify loader, previously a loose
  `iconify_loader.py` in the repo root, now ships inside the package. Converts
  any of Iconify's 150+ icon sets into `custom=` categories. `requests` is an
  opt-in extra (`pip install "dash-emoji-mart[iconify]"`) and imports lazily, so
  the core package still depends on nothing but `dash`.
- **Documentation site** at <https://emojimart.2plot.dev> — eight
  markdown-driven pages with live, editable examples, `llms.txt` for every page,
  a sitemap and robots.txt.
- **2plot network membership.** The site now publishes the cross-host directory
  (`lib/network_directory.py`, copied verbatim from the documentation
  boilerplate so every satellite advertises the same graph): `rel="related"`
  tags in `<head>`, a `## Network` section in `/llms.txt`, and followed links in
  the prerendered body. Search engines follow cross-host links weakly and agents
  do not follow them at all, so without this an agent landing here sees one
  library and nothing saying the rest of the network exists.
- **Traffic reporting to the 2plot.ai hub** — `lib/analytics_tracker.py` (the
  visitor ledger), `lib/traffic_rollup.py` (daily rollup) and
  `lib/satellite_reporter.py` (the signed hourly POST), replacing the
  hand-rolled `lib/satellite_analytics.py`. Dormant without
  `CROSS_APP_WEBHOOK_SECRET`.
- **`scripts/verify_network.py`** — the 2plot migration battery (canonical
  count, robots fingerprint, directive leakage, peer reachability) runnable
  in-process against the Flask test client.
- **CI** — every push runs the docs smoke suite against Dash 4.1.0, 4.2.0,
  4.3.0 and 4.4.1, builds the wheel, installs it into a clean venv with nothing
  but `dash` present, and imports it on Python 3.9 through 3.13.
- **`scripts/smoke_test.py`** and **`scripts/check_release.py`** — the same
  checks CI runs, runnable locally.

### Changed

- **Requires `dash>=4.1` and Python >=3.9.** The Python component classes are
  now generated by `dash-generate-components` under Dash 4; 0.0.x was generated
  under Dash 3. The CI matrix measures the whole claimed range on every push.
- **The documentation site requires `dash-improve-my-llms[flask]>=2.3.3**
  (site-only; the package's own dependency list is untouched and still just
  `dash>=4.1`). Three things in that range are load-bearing here: merge
  semantics for `register_page_metadata` (2.2.0), without which `run.py`'s root
  re-registration silently blanked the home page's prose and served crawlers a
  "requires JavaScript" stub on the site's most valuable URL; the universal
  prerender that writes canonical/OpenGraph/`<title>` server-side per page
  (2.2/2.3); and the corrected AI-crawler taxonomy (2.3.2/2.3.3).
- **`block_ai_training` is now `True`.** Before 2.3.2 this flag also blocked
  the user-initiated fetchers through legacy agent aliases, which broke
  claude.ai fetches — so the site ran `False`. The taxonomy fix means `True`
  now refuses only bulk training crawlers (GPTBot, ClaudeBot, CCBot) while
  Claude-User, Claude-SearchBot, ChatGPT-User and OAI-SearchBot stay allowed.
  The docs remain reachable by anything a person actually asked to fetch them.
- **`SATELLITE_APP_ID` → `SATELLITE_APP_KEY`** in `render.yaml`. The new
  reporter reads the latter and defaulted to the boilerplate's own directory
  key, so a deploy that kept the old variable name would have reported this
  app's traffic as the boilerplate's and overwritten its rows on the hub.

### Fixed (documentation site)

- **Retired the canonical shim in `templates/index.html`** — a hard-coded
  `<link rel="canonical">` plus a ~40-line `history.pushState` monkey-patch
  that rewrote the canonical and URL meta tags client-side from a hard-coded
  origin, ignoring `DASH_EMOJI_MART_BASE_URL`. The prerender does this
  server-side, per page; keeping the shim would have emitted two canonical tags
  on every page.
- **The prerender was silently disabled by an HTML comment.** Its idempotency
  guard is a substring test for its marker attribute across the whole document,
  and HTML comments are part of the document — so a comment *describing* the
  marker convinced the injector it had already run. Every page served with no
  canonical tag, no OpenGraph tags and no prerendered body for crawlers, while
  looking perfectly normal in a browser. `scripts/verify_network.py` now
  regression-tests for it, and the template comment describes the attribute
  without spelling it.
- SPA page views are counted again after the analytics swap. The boilerplate's
  tracker skips `/_dash-update-component` and ships no beacon, so a
  straight replacement would have counted only entry pages and reported every
  session as single-page. `lib/pageview_beacon.py` ports the old beacon onto
  the new tracker, feeding the same ledger.
- **The site index no longer prints its tagline twice.** `/llms.txt` emits
  `# <name>` and `> <description>` from the page metadata, then splices in the
  root page's prose — stripping a leading H1 from it, but not a leading
  blockquote. The loader's preamble therefore surfaced as the same tagline
  repeated at the top of the index. The preamble is now skipped for `/` only;
  per-page docs still carry their own title and tagline, because the package
  returns those verbatim and they would otherwise arrive untitled.
- **`run.py` refuses to start on `dash-improve-my-llms` below 2.3.3**, and
  prints the resolved package version and interpreter path at startup. Below
  the floor the site boots cleanly and is broken only where nobody looks: on
  <2.2 the root `register_page_metadata` call blanks the home page's prose so
  `/llms.txt` serves `_No LLMS_DOC registered for /._`, and on <2.1 the whole
  `## Network` section silently disappears. A server started before a
  dependency upgrade keeps the old version in memory and serves exactly that,
  indefinitely, with no signal — which is precisely how it was found.
- **Directive options no longer leak into `llms.txt`.** The loader expanded
  `.. source::path` into a fenced code block but left the directive's indented
  `:defaultExpanded:` / `:withExpandedButton:` lines behind — and with the
  directive line gone, the package's own stripper had no block to recognise, so
  the orphaned options shipped as noise in every page's agent-facing markdown.
  The loader now consumes the option lines with the directive, and
  `scripts/verify_network.py` checks for orphaned options as well as for
  unexpanded directive lines (the runbook's `^\.\. \w+::` pattern matches only
  the latter, which is why this got through).
- **The documentation examples no longer pass `categories` alongside `custom`.**
  The combination is only safe on the first picker initialised in a page's
  lifetime — see the note below — so the Iconify page rendered its default set
  and then an empty grid for every set switched to afterwards, and the
  custom-emoji page rendered correctly or not depending on which page the
  visitor had opened first.
- **Packaging moved from `setup.py` to `pyproject.toml`.** The distribution now
  contains only the component package — the documentation site, the JS source
  and the react-docgen `metadata.json` no longer ship. CI asserts this against
  the built wheel.
- **Repository layout flattened.** The project lived in a nested
  `emoji_mart/dash_emoji_mart/` directory; everything is now at the repository
  root, matching the `dash-leaflet2` layout.
- `LICENSE` was a zero-byte file in 0.0.x. It now contains the MIT licence the
  package has always claimed.

### Known upstream limitation

- **`categories` cannot be combined with `custom`.** emoji-mart filters the
  `categories` prop against an internal `originalCategories` array that it
  captures **by reference** on the first `init()` in a page's lifetime. Custom
  categories pushed during that first init land in it through the aliasing, so
  the pair appears to work. Every later init rebuilds the category list as a new
  array, breaking the alias, and the filter then runs against a stale list that
  cannot contain the custom ids — dropping every custom category silently.

  A remount does not help; it is what triggers the failure. There is no
  `categories` value that avoids it. Omit the prop when passing `custom`, and
  your categories are appended after the built-ins. Documented on
  [Custom emojis](https://emojimart.2plot.dev/custom-emojis), the API reference
  and the README.

### Removed

- **The R and Julia backends.** `R/`, `man/`, `inst/`, `NAMESPACE`,
  `DESCRIPTION`, `Project.toml` and `src/jl/` were half-generated and never
  regenerated after the first release. `dash-generate-components` now runs
  Python-only.
- **Four unusable props: `onEmojiSelect`, `onClickOutside`, `onAddCustomEmoji`
  and `getSpritesheetURL`.** All four were declared `PropTypes.func`. Dash
  serialises props to JSON and a Python function is not serialisable, so passing
  any of them raised — they were always `None` in practice. Use
  `Input(id, "value")` / `Input(id, "selectedEmoji")` and
  `Input(id, "clickedOutside")` instead.
- **`ramda`** from the JS dependencies — nothing imported it.
- The React-only demo harness (`src/demo/`, `webpack.serve.config.js`,
  `index.html`). `python run.py` is now the single way to run the component
  locally.

### Fixed

- **Custom emojis whose `src` is a dimensionless SVG are no longer invisible.**
  emoji-mart sizes a custom emoji's image with `max-width`/`max-height` and
  nothing else. A raster source has intrinsic dimensions and scales down to fit;
  an SVG whose root element carries only a `viewBox` — no `width`/`height` — has
  no intrinsic size, so `width: auto` under a bare `max-width` resolves to zero
  and the emoji renders as a 0×0 box: in the DOM, focusable, clickable,
  invisible.

  The failure looked arbitrary rather than systematic, because it depends only on
  what the author happened to link. Iconify's API SVGs ship `width="1em"
  height="1em"` and were always fine; SVGs straight off a repo (devicons and most
  icon projects) were not — so one custom category would render and the next
  would be a blank strip under its heading.

  The component now adopts a stylesheet into the picker's shadow root giving
  custom-emoji images `width: 1em; height: 1em; object-fit: contain`. `1em` is
  the size emoji-mart already intends — it sets font-size to `emojiSize` on each
  grid button and to the larger preview size in the footer — so one rule is
  correct in both places, non-square artwork is not distorted, and raster sources
  render exactly as before.
- **The Iconify cache no longer writes inside the installed package.**
  `iconify_loader.py` cached API responses to `Path(__file__).parent /
  ".iconify_cache"`, which is inside `site-packages` for an installed package
  and typically not writable. The cache now lives under the system temp
  directory, relocatable with `DASH_EMOJI_MART_CACHE_DIR`.
- **`__init__.py` no longer registers resources that do not exist.** It
  advertised `async-*.js` chunks the webpack config never emits, and a
  `.js.map` entry Dash serves on demand anyway.
- Iconify network failures are now contained: a request that fails logs a
  warning and returns empty, so a picker renders without its optional icon set
  rather than the page erroring.

## [0.0.5] — 2026-01-08

### Fixed

- Multiple custom emoji categories can be set at once.

## [0.0.3]

- Initial PyPI release, and still the newest version on PyPI until `v0.2.1` is
  tagged. Errors on import under Dash 4 with React 18.2; use 0.2.1.

[0.2.1]: https://github.com/pip-install-python/dash-emoji-mart/releases/tag/v0.2.1
[0.0.5]: https://github.com/pip-install-python/dash-emoji-mart/releases/tag/v0.0.5
[0.0.3]: https://github.com/pip-install-python/dash-emoji-mart/releases/tag/v0.0.3

---

<!-- /configuration — https://emojimart.2plot.dev/configuration/llms.txt -->

# Configuration

> Every layout, sizing and locale prop, wired to live controls so you can see what each one does.

---



### Overview

The picker's shape is entirely prop-driven. The demo below wires every layout prop to a
control so you can dial in the configuration you want and copy the resulting call.

### Live demo


### Sizing

| Prop | Default | What it does |
|------|---------|--------------|
| `perLine` | `9` | Emojis per row. Also sets the picker's width unless `dynamicWidth` is on. |
| `emojiSize` | `24` | Size of the glyph, in px. |
| `emojiButtonSize` | `36` | Size of the button around the glyph, in px. |
| `emojiButtonRadius` | `"100%"` | Any CSS radius — `"6px"` gives square-ish buttons. |
| `dynamicWidth` | `False` | Let the picker fill its container instead of sizing from `perLine`. |


    With `dynamicWidth=True` the picker measures its parent, so `perLine` stops having a
    visible effect. Put it in a fixed-width parent, or leave `dynamicWidth` off.

### Layout

| Prop | Default | Options |
|------|---------|---------|
| `navPosition` | `"top"` | `"top"`, `"bottom"`, `"none"` |
| `previewPosition` | `"bottom"` | `"top"`, `"bottom"`, `"none"` |
| `searchPosition` | `"sticky"` | `"sticky"`, `"static"`, `"none"` |
| `skinTonePosition` | `"preview"` | `"preview"`, `"search"`, `"none"` |
| `maxFrequentRows` | `4` | `0` hides the "frequently used" category entirely. |

### Content

`categories` picks which built-in categories appear, and in what order. Leaving it empty
(the default) shows all of them:

```python
DashEmojiMart(
    id="picker",
    categories=["frequent", "people", "nature", "foods"],
    exceptEmojis=["rage", "cry"],
    noCountryFlags=True,
)
```

The built-in category ids are `frequent`, `people`, `nature`, `foods`, `activity`,
`places`, `objects`, `symbols` and `flags`.


    Do not list custom category ids here, and do not pass this prop at all when you pass
    `custom`. emoji-mart filters it against a category list it snapshots on the first
    picker initialised in the page's lifetime, so custom ids resolve on that first picker
    and are silently dropped on every one after it. See
    [Custom emojis](/custom-emojis#ordering-custom-categories) for the full mechanism.

### Emoji set and locale

`set` chooses which artwork the picker renders: `"native"` (the default — the OS's own
emoji font, no images fetched), or `"apple"`, `"google"`, `"twitter"`, `"facebook"`, which
load spritesheets from jsDelivr.

`locale` translates the UI and search index. `"en"`, `"ar"`, `"be"`, `"cs"`, `"de"`,
`"es"`, `"fa"`, `"fi"`, `"fr"`, `"hi"`, `"it"`, `"ja"`, `"ko"`, `"nl"`, `"pl"`, `"pt"`,
`"ru"`, `"sa"`, `"tr"`, `"uk"`, `"vi"` and `"zh"` ship with emoji-mart.


    Two controls above can take a category away and not give it back:

    * set `maxFrequentRows` to `0` and the Frequently-used section disappears —
      setting it back to `4` does not bring it back;
    * pick a few `categories`, then clear the field. "All of them" does not return.

    Both are the same emoji-mart behaviour, and it is global rather than per-picker.
    `init()` builds `Data.categories` once and then mutates that array in place: a
    category that ends up empty is `splice`d straight out of it. The only code path
    that rebuilds the list from `Data.originalCategories` is the one that runs when
    you pass `categories` — which is why choosing categories restores Frequently-used
    while clearing them does not.

    So the state lives in a module global that outlives the component, the page and
    every remount. Reloading the page is the only reset. If a category needs to come
    and go at runtime, pass `categories` explicitly every time rather than relying on
    the empty default.


    Turn `noCountryFlags` on above, then type "united" into the picker's search box. The
    flags category has shrunk to a short safe list — and the search still returns the
    flags of the UK, the US, the UAE and the UN.

    Measured against emoji-mart 5.6.0, and it is the same code path for both props: the
    filter runs while each category is built and splices the emoji out of
    `category.emojis`, while `SearchIndex.search` matches over
    `Object.values(Data.emojis)` — the unfiltered map — and applies no category filter of
    its own.

    Not worked around in this component, deliberately. emoji-mart loads its data into a
    module-global exactly once per page, so pre-filtering the data for one picker would
    silently change every other picker on the page, and every page after it in a Dash
    SPA — the same aliasing trap described on [Custom emojis](/custom-emojis). A visible
    search result beats an invisible, mount-order-dependent one.

    Use them for tidying, never as access control. To keep an emoji away from a user
    entirely, pass a trimmed data set rather than a filter.


    emoji-mart reads both once, when it initialises its internal store. Updating either
    prop on a live picker leaves the old data in place. Wrap the picker in a
    `html.Div(..., id=...)` whose **id** varies with the value, as the demo above does —
    Dash keys every child on its id, so a new id means React discards the old picker
    instead of patching it.

    Not `key=`. Dash's html components accept a `key` prop, but the renderer never reads
    it when reconciling: it builds the React key from the component's id
    (`key: container.props.id ? stringifyId(container.props.id) : stringifyPath(path)`).
    A `key=` therefore forces no remount and reaches the `<div>` as a stray prop, which
    is the source of the `` `key` is not a prop `` warning in the console.

### Source


```python
# File: docs/configuration/example.py

"""Every layout prop bound to a live control, plus the generated call.

Note the varying `id=` on the wrapper Div. emoji-mart reads `set` and `locale` once when
it builds its internal store, so changing either on a mounted picker does nothing
visible; the picker has to be unmounted and remounted for the data to reload.

The id is what triggers that. Dash's renderer keys each child on its id —
`key: container.props.id ? stringifyId(container.props.id) : stringifyPath(path)` in
DashWrapper — so a different wrapper id is a different React key, and React discards the
old subtree instead of patching it. A `key=` prop does NOT do this: html components
accept it, but the renderer never reads it for reconciliation, so it only reaches the
`<div>` as a stray prop and React warns "`key` is not a prop".
"""

import dash_mantine_components as dmc
from dash import Input, Output, callback, html

from dash_emoji_mart import DashEmojiMart

CATEGORY_OPTIONS = [
    {"value": "frequent", "label": "Frequently used"},
    {"value": "people", "label": "People"},
    {"value": "nature", "label": "Nature"},
    {"value": "foods", "label": "Food & drink"},
    {"value": "activity", "label": "Activities"},
    {"value": "places", "label": "Travel & places"},
    {"value": "objects", "label": "Objects"},
    {"value": "symbols", "label": "Symbols"},
    {"value": "flags", "label": "Flags"},
]


def _select(component_id, label, options, value):
    return dmc.Select(
        id=component_id,
        label=label,
        data=[{"value": v, "label": lbl} for v, lbl in options],
        value=value,
        allowDeselect=False,
    )


controls = dmc.Stack(
    [
        dmc.SimpleGrid(
            [
                dmc.NumberInput(
                    id="cfg-per-line", label="perLine", value=9, min=4, max=16
                ),
                dmc.NumberInput(
                    id="cfg-emoji-size", label="emojiSize", value=24, min=14, max=48, step=2
                ),
                dmc.NumberInput(
                    id="cfg-button-size",
                    label="emojiButtonSize",
                    value=36,
                    min=22,
                    max=64,
                    step=2,
                ),
                dmc.NumberInput(
                    id="cfg-max-frequent", label="maxFrequentRows", value=4, min=0, max=8
                ),
            ],
            cols={"base": 2, "md": 4},
            spacing="md",
        ),
        dmc.SimpleGrid(
            [
                _select(
                    "cfg-nav",
                    "navPosition",
                    [("top", "top"), ("bottom", "bottom"), ("none", "none")],
                    "top",
                ),
                _select(
                    "cfg-preview",
                    "previewPosition",
                    [("top", "top"), ("bottom", "bottom"), ("none", "none")],
                    "bottom",
                ),
                _select(
                    "cfg-search",
                    "searchPosition",
                    [("sticky", "sticky"), ("static", "static"), ("none", "none")],
                    "sticky",
                ),
                _select(
                    "cfg-skin-tone",
                    "skinTonePosition",
                    [("preview", "preview"), ("search", "search"), ("none", "none")],
                    "preview",
                ),
            ],
            cols={"base": 2, "md": 4},
            spacing="md",
        ),
        dmc.SimpleGrid(
            [
                _select(
                    "cfg-set",
                    "set",
                    [
                        ("native", "native"),
                        ("apple", "apple"),
                        ("google", "google"),
                        ("twitter", "twitter"),
                        ("facebook", "facebook"),
                    ],
                    "native",
                ),
                _select(
                    "cfg-locale",
                    "locale",
                    [
                        ("en", "English"),
                        ("es", "Español"),
                        ("fr", "Français"),
                        ("de", "Deutsch"),
                        ("ja", "日本語"),
                        ("zh", "中文"),
                    ],
                    "en",
                ),
                dmc.NumberInput(id="cfg-skin", label="skin", value=1, min=1, max=6),
                dmc.TextInput(
                    id="cfg-radius", label="emojiButtonRadius", value="100%"
                ),
            ],
            cols={"base": 2, "md": 4},
            spacing="md",
        ),
        dmc.MultiSelect(
            id="cfg-categories",
            label="categories (empty shows all)",
            data=CATEGORY_OPTIONS,
            value=[],
            clearable=True,
        ),
        dmc.Group(
            [
                dmc.Switch(id="cfg-dynamic-width", label="dynamicWidth", checked=False),
                dmc.Switch(id="cfg-auto-focus", label="autoFocus", checked=False),
                dmc.Switch(id="cfg-no-flags", label="noCountryFlags", checked=False),
            ],
            gap="xl",
        ),
    ],
    gap="md",
)

component = dmc.Stack(
    [
        dmc.Paper(controls, withBorder=True, p="lg", radius="md"),
        dmc.Group(
            [
                # `flex: 1` and `minWidth: 0` are what make `dynamicWidth`
                # demonstrable rather than broken.
                #
                # dynamicWidth tells emoji-mart to set `width: 100%` instead of
                # sizing itself from `perLine`. A bare Div inside this flex
                # Group is `flex: 0 1 auto`, so its own width comes from its
                # content — and the content is now asking for 100% OF THAT.
                # The circular constraint resolves at min-content, so turning
                # the switch on made the picker COLLAPSE from 352px to 190px
                # and the grid reflow under the pointer, which reads as the
                # component glitching.
                #
                # Giving the mount a real width to fill fixes it: `flex: 1`
                # makes it claim the leftover row space, `minWidth: 0` lets it
                # shrink below its content's intrinsic width instead of forcing
                # the Group wider.
                html.Div(id="cfg-picker", style={"flex": 1, "minWidth": 0}),
                dmc.Stack(
                    [
                        dmc.Paper(
                            [
                                dmc.Text("Selected", size="sm", c="dimmed"),
                                html.Div(
                                    id="cfg-output",
                                    style={"fontSize": 48, "minHeight": 60},
                                ),
                            ],
                            withBorder=True,
                            p="lg",
                            radius="md",
                            style={"minWidth": 200, "textAlign": "center"},
                        ),
                        dmc.CodeHighlight(
                            id="cfg-code", code="", language="python"
                        ),
                    ],
                    gap="md",
                    style={"flex": 1, "minWidth": 300},
                ),
            ],
            align="flex-start",
            gap="xl",
        ),
    ],
    gap="md",
)


@callback(
    Output("cfg-picker", "children"),
    Output("cfg-code", "code"),
    Input("cfg-per-line", "value"),
    Input("cfg-emoji-size", "value"),
    Input("cfg-button-size", "value"),
    Input("cfg-max-frequent", "value"),
    Input("cfg-nav", "value"),
    Input("cfg-preview", "value"),
    Input("cfg-search", "value"),
    Input("cfg-skin-tone", "value"),
    Input("cfg-set", "value"),
    Input("cfg-locale", "value"),
    Input("cfg-skin", "value"),
    Input("cfg-radius", "value"),
    Input("cfg-categories", "value"),
    Input("cfg-dynamic-width", "checked"),
    Input("cfg-auto-focus", "checked"),
    Input("cfg-no-flags", "checked"),
)
def rebuild(
    per_line,
    emoji_size,
    button_size,
    max_frequent,
    nav,
    preview,
    search,
    skin_tone,
    emoji_set,
    locale,
    skin,
    radius,
    categories,
    dynamic_width,
    auto_focus,
    no_flags,
):
    props = {
        "perLine": per_line if per_line is not None else 9,
        "emojiSize": emoji_size if emoji_size is not None else 24,
        "emojiButtonSize": button_size if button_size is not None else 36,
        "emojiButtonRadius": radius or "100%",
        "maxFrequentRows": max_frequent if max_frequent is not None else 4,
        "navPosition": nav or "top",
        "previewPosition": preview or "bottom",
        "searchPosition": search or "sticky",
        "skinTonePosition": skin_tone or "preview",
        "set": emoji_set or "native",
        "locale": locale or "en",
        "skin": skin if skin is not None else 1,
        "dynamicWidth": bool(dynamic_width),
        "autoFocus": bool(auto_focus),
        "noCountryFlags": bool(no_flags),
    }
    if categories:
        props["categories"] = categories

    rendered = ",\n".join(f"    {k}={v!r}" for k, v in props.items())
    code = f'DashEmojiMart(\n    id="picker",\n{rendered},\n)'

    # `set` and `locale` only take effect on a fresh mount, so they go in the wrapper id
    # — that is what Dash turns into the React key. See the module docstring.
    mount_id = f"cfg-mount-{props['set']}-{props['locale']}"
    return html.Div(DashEmojiMart(id="cfg-emoji-picker", **props), id=mount_id), code


@callback(Output("cfg-output", "children"), Input("cfg-emoji-picker", "value"))
def show(value):
    if not value:
        return dmc.Text("Nothing yet", c="dimmed", size="lg")
    if value.startswith("http"):
        return html.Img(src=value, style={"width": 48, "height": 48})
    return value
```


---

*Source: /configuration*

---

<!-- /custom-emojis — https://emojimart.2plot.dev/custom-emojis/llms.txt -->

# Custom Emojis

> Add your own categories of images, GIFs and SVGs — with their own nav icons — alongside the built-in emojis.

---



### Overview

`custom` takes a list of categories. Each category has an `id`, a display `name`, and a
list of `emojis`, and each emoji points at an image URL:

```python
CUSTOM = [
    {
        "id": "team",
        "name": "Team",
        "emojis": [
            {
                "id": "party_parrot",
                "name": "Party Parrot",
                "short_names": ["party_parrot"],
                "keywords": ["dance", "party", "parrot"],
                "skins": [{"src": "https://example.com/parrot.gif"}],
                "native": "",
                "unified": "custom",
            },
        ],
    },
]

DashEmojiMart(id="picker", custom=CUSTOM)
```

Anything the browser can render in an `<img>` works: PNG, SVG, animated GIF, WebP.


    emoji-mart sizes a custom emoji's image with `max-width`/`max-height` alone. A raster
    source has intrinsic dimensions and scales down to fit, but an SVG carrying only a
    `viewBox` has no intrinsic size, so it collapsed to 0x0 — present and clickable, but
    invisible. Raw SVGs off a repo (devicons and most icon projects) hit this; Iconify's
    API SVGs ship `width="1em"` and never did.

    The component now sizes custom-emoji images in the picker's shadow root, so any SVG
    works regardless of what its root element declares. Nothing is required of your data.

### Live demo


### The emoji object

| Key | Required | Notes |
|-----|----------|-------|
| `id` | yes | Unique across every category. This is what `selectedEmoji["id"]` returns. |
| `name` | yes | Shown in the preview pane. |
| `skins` | yes | A list of `{"src": url}`. Custom emojis have no skin tones — give it one entry. |
| `keywords` | no | Extra search terms. `name` and `id` are already indexed. |
| `short_names` | no | Shortcode aliases, searchable with a leading `:`. |
| `native` | no | Leave as `""`. Custom emojis have no glyph. |
| `unified` | no | Leave as `"custom"`. |

### What a callback receives

Custom emojis have no native glyph, so `value` is the **image URL** rather than a
character. That is the one behavioural difference from a built-in emoji, and the reason
most readouts branch on it:

```python
@callback(Output("out", "children"), Input("picker", "value"))
def show(value):
    if not value:
        return "Nothing picked"
    if value.startswith("http"):
        return html.Img(src=value, style={"height": 48})
    return value
```

`selectedEmoji` avoids the guesswork — a custom emoji's object carries `src`, a built-in
one carries `native`. See [Callbacks & props](/callbacks).

### Category icons

`categoryIcons` maps a category `id` to the icon shown in the nav bar. The value is an
object with an `svg` key holding inline SVG markup:

```python
DashEmojiMart(
    id="picker",
    custom=CUSTOM,
    categoryIcons={
        "team": {
            "svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
                   'width="1em" height="1em"><path fill="currentColor" d="..."/></svg>'
        },
    },
)
```


    `width="1em" height="1em"` lets the icon scale with the nav bar, and
    `fill="currentColor"` lets it pick up the active/inactive colours emoji-mart applies.
    A hard-coded `width="24"` and a literal colour will look wrong in one of the themes.

### Ordering custom categories

Custom categories are appended after the built-in ones, in the order you list them in
`custom`. That order you control; their position relative to the built-ins you do not.


    It appears to work and then fails, which is worse than failing outright.

    emoji-mart filters `categories` against an internal `originalCategories` array that
    it captures **by reference** on the first picker initialised in the page's lifetime.
    Custom categories pushed during that first init land in it through the aliasing, so
    the combination looks fine. Every later init rebuilds the category list as a *new*
    array, breaking the alias — so from the second picker onwards the filter runs against
    a stale list that cannot contain your custom ids, and **every custom category is
    silently dropped**.

    In a multi-page app that makes rendering depend on which page the user opened first.
    The picker on this page renders custom emojis on a cold load and an empty grid after
    you have visited [Iconify icon sets](/iconify) — for as long as the tab lives.

    There is no `categories` value that avoids this. Omit the prop when you use `custom`;
    the filter is then skipped entirely and your categories survive.

If you need *only* your own categories with no built-ins at all, the reliable route is
to pass a trimmed emoji data set rather than to filter with `categories`.

### Source


```python
# File: docs/custom-emojis/example.py

"""Three custom categories — memes, tech and company — with their own nav icons.

The readout branches on `value.startswith("http")` because a custom emoji has no native
glyph: `value` is the image URL. The name underneath comes from `selectedEmoji`, which
identifies the pick without that guesswork.
"""

import dash_mantine_components as dmc
from dash import Input, Output, callback, html

from dash_emoji_mart import DashEmojiMart

DEVICON = "https://cdn.jsdelivr.net/gh/devicons/devicon/icons"


def emoji(emoji_id, name, src, keywords):
    return {
        "id": emoji_id,
        "name": name,
        "short_names": [emoji_id],
        "keywords": keywords,
        "skins": [{"src": src}],
        "native": "",
        "unified": "custom",
    }


CUSTOM_EMOJIS = [
    {
        "id": "memes",
        "name": "Memes",
        "emojis": [
            emoji(
                "party_parrot",
                "Party Parrot",
                "https://missiveapp.com/open/emoji-mart/parrot.6a845cb2.gif",
                ["dance", "dancing", "party", "parrot"],
            ),
            emoji(
                "shiba",
                "Shiba",
                "https://cdn.jsdelivr.net/gh/iamcal/emoji-data@15.1.2/img-apple-160/1f436.png",
                ["dog", "meme", "shiba"],
            ),
        ],
    },
    {
        "id": "tech",
        "name": "Tech",
        "emojis": [
            emoji(
                "python",
                "Python",
                f"{DEVICON}/python/python-original.svg",
                ["python", "programming", "code"],
            ),
            emoji(
                "react",
                "React",
                f"{DEVICON}/react/react-original.svg",
                ["react", "javascript", "frontend"],
            ),
            emoji(
                "docker",
                "Docker",
                f"{DEVICON}/docker/docker-original.svg",
                ["docker", "container", "devops"],
            ),
        ],
    },
    {
        "id": "company",
        "name": "Company",
        "emojis": [
            emoji(
                "github",
                "GitHub",
                f"{DEVICON}/github/github-original.svg",
                ["github", "git", "repo"],
            ),
            emoji(
                "slack",
                "Slack",
                f"{DEVICON}/slack/slack-original.svg",
                ["slack", "chat", "communication"],
            ),
        ],
    },
]

# Inline SVG for each custom category's nav icon. `1em` sizing and
# `fill="currentColor"` are what let emoji-mart scale and colour them.
_SVG = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" '
    'viewBox="0 0 24 24"><path fill="currentColor" d="{path}"/></svg>'
)

CATEGORY_ICONS = {
    "memes": {
        "svg": _SVG.format(
            path="M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 "
            "12 2m3.5 6c.83 0 1.5.67 1.5 1.5S16.33 11 15.5 11S14 10.33 14 9.5S14.67 8 "
            "15.5 8m-7 0c.83 0 1.5.67 1.5 1.5S9.33 11 8.5 11S7 10.33 7 9.5S7.67 8 8.5 "
            "8m3.5 9.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5"
        )
    },
    "tech": {
        "svg": _SVG.format(
            path="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 "
            "2H0v2h24v-2zM4 6h16v10H4z"
        )
    },
    "company": {
        "svg": _SVG.format(
            path="M18 15h-2v2h2m0-6h-2v2h2m2 6h-8v-2h2v-2h-2v-2h2v-2h-2V9h8M10 7H8V5h2m0 "
            "6H8V9h2m0 6H8v-2h2m0 6H8v-2h2M6 7H4V5h2m0 6H4V9h2m0 6H4v-2h2m0 6H4v-2h2m6-10V3"
            "H2v18h20V7z"
        )
    },
}

component = dmc.Group(
    [
        DashEmojiMart(
            id="custom-picker",
            custom=CUSTOM_EMOJIS,
            categoryIcons=CATEGORY_ICONS,
            # No `categories` here, on purpose — see the note on this page.
            # Passing it alongside `custom` only works for the FIRST picker
            # mounted in a page's lifetime; after that emoji-mart filters the
            # custom ids against a stale internal list and drops them all. On a
            # multi-page docs site that makes rendering depend on which page you
            # visited first, which is about the worst kind of bug to chase.
            perLine=9,
            emojiSize=24,
            maxFrequentRows=1,
        ),
        dmc.Paper(
            [
                dmc.Text("Selected", size="sm", c="dimmed"),
                html.Div(
                    id="custom-out",
                    style={"fontSize": 48, "minHeight": 64},
                ),
                dmc.Text(id="custom-name", size="sm", fw=500),
                dmc.Text(id="custom-kind", size="xs", c="dimmed"),
            ],
            withBorder=True,
            p="lg",
            radius="md",
            style={"minWidth": 200, "textAlign": "center"},
        ),
    ],
    align="flex-start",
    gap="xl",
)


@callback(
    Output("custom-out", "children"),
    Output("custom-name", "children"),
    Output("custom-kind", "children"),
    Input("custom-picker", "value"),
    Input("custom-picker", "selectedEmoji"),
)
def show(value, emoji_obj):
    if not value:
        return dmc.Text("Nothing yet", c="dimmed", size="lg"), "", ""

    name = (emoji_obj or {}).get("name", "")
    if value.startswith("http"):
        glyph = html.Img(src=value, style={"height": 48, "width": 48})
        kind = "custom — value is an image URL"
    else:
        glyph = value
        kind = "built-in — value is the native glyph"
    return glyph, name, kind
```


---

*Source: /custom-emojis*

---

<!-- /iconify — https://emojimart.2plot.dev/iconify/llms.txt -->

# Iconify Icon Sets

> Turn any of Iconify's 150+ icon sets — twemoji, OpenMoji, Fluent Emoji, Tabler — into pickable categories.

---



### Overview

[Iconify](https://icon-sets.iconify.design/) hosts over 150 icon sets, several of them
emoji sets far larger than the one emoji-mart bundles — twemoji has ~4,000 glyphs,
OpenMoji ~4,200. `dash_emoji_mart.iconify` fetches a set's index and reshapes it into the
category structure `custom=` expects:

```python
from dash_emoji_mart import DashEmojiMart
from dash_emoji_mart.iconify import iconify_to_emoji_mart

DashEmojiMart(
    id="picker",
    custom=iconify_to_emoji_mart("twemoji", max_icons_per_category=60),
)
```

Because Iconify icons are images, `value` comes back as the icon's SVG URL — the same as
any other [custom emoji](/custom-emojis).

### Install

This is the only part of the package that reaches the network, so `requests` is an opt-in
extra and the module imports lazily. `import dash_emoji_mart` never needs it:

```bash
pip install "dash-emoji-mart[iconify]"
```

### Live demo


### API


    Convert one or more sets into `custom=` categories.

    * **prefixes** — a prefix or list of them, e.g. `"twemoji"` or `["twemoji", "noto"]`.
    * **max_icons_per_category** — cap per category; `-1` for no cap. The default of 100
      exists because a whole set can be thousands of icons and every one becomes an
      `<img>` in the picker.
    * **include_categories** / **exclude_categories** — case-insensitive filters.
    * **flatten** — merge each set's categories into a single category per prefix.

Also available:

| Function | Returns |
|----------|---------|
| `get_emoji_icon_sets()` | Curated emoji-style sets, keyed by prefix. |
| `get_icon_sets()` | Curated general-purpose (non-emoji) sets. |
| `get_all_collections()` | Every set Iconify hosts. |
| `get_collection_info(prefix)` | One set's categories and icon names. |
| `search_icons(query, prefix=None, limit=50)` | Matching icons as emoji dicts. |
| `get_svg_url(prefix, icon)` | The CDN URL for one icon. |
| `clear_cache()` | Drop the disk and in-process caches. |

### Caching

Responses are memoised in-process and cached on disk for 24 hours, so a page that builds
its categories at import time pays the API cost once per day per container rather than
once per page view.

The cache lives outside the installed package — a pip-installed package is usually not
writable. Set `DASH_EMOJI_MART_CACHE_DIR` to relocate it:

```bash
export DASH_EMOJI_MART_CACHE_DIR=/var/cache/dash-emoji-mart
```


    `iconify_to_emoji_mart` can make several API calls on a cold cache. Call it once at
    module level so the cost lands at startup; calling it inside a callback puts a network
    round-trip on the critical path of every interaction.

### Failure behaviour

Every request is wrapped: a network failure logs a warning and returns `{}`, so
`iconify_to_emoji_mart` yields an empty list and the picker renders without the optional
set. A page that loses its icon categories beats a page that 500s because
`api.iconify.design` had a bad minute.


    Selecting a set filters what this demo *loads*, not what emoji-mart *shows*: its nine
    built-in categories are still there, ahead of the Iconify ones. `categories` looks
    like the fix and is not — measured both ways on this page, it filters correctly for
    the first set chosen and renders an **empty picker** for every set switched to
    afterwards, because emoji-mart resolves that prop against a category list it captured
    on the first init of the page's lifetime. Registering all three sets up front does not
    help either. See [Custom emojis](/custom-emojis) for the underlying aliasing.

    So the demo jumps to the Iconify section instead of hiding the built-ins: choosing a
    set clicks the picker's own custom-category nav button. If you genuinely need a picker
    with no built-in emoji at all, pass a trimmed emoji data set rather than a filter.

### Source


```python
# File: docs/iconify/example.py

"""A picker whose categories come from Iconify rather than emoji-mart's own data.

Categories are built at import time, not in the callback: on a cold cache
`iconify_to_emoji_mart` makes a handful of API calls, and that cost belongs at startup
rather than on the critical path of a click. Switching sets in the demo only swaps between
sets already materialised in `SETS` below.

If api.iconify.design is unreachable the loader returns an empty list and the picker falls
back to plain emojis, which is why there is no error branch here.
"""

import dash_mantine_components as dmc
from dash import Input, Output, callback, clientside_callback, html

from dash_emoji_mart import DashEmojiMart
from dash_emoji_mart.iconify import iconify_to_emoji_mart

# Three sets with quite different characters: Twitter's emoji, the open-source
# OpenMoji project, and Tabler — a non-emoji UI icon set, to show that the
# conversion is not emoji-specific.
SET_PREFIXES = ["twemoji", "openmoji", "tabler"]

SETS = {
    prefix: iconify_to_emoji_mart(prefix, max_icons_per_category=48)
    for prefix in SET_PREFIXES
}

component = dmc.Stack(
    [
        dmc.SegmentedControl(
            id="iconify-set",
            data=[
                {"value": "twemoji", "label": "Twemoji"},
                {"value": "openmoji", "label": "OpenMoji"},
                {"value": "tabler", "label": "Tabler"},
            ],
            value="twemoji",
        ),
        dmc.Text(id="iconify-count", size="sm", c="dimmed"),
        dmc.Group(
            [
                html.Div(id="iconify-picker"),
                dmc.Paper(
                    [
                        dmc.Text("Selected", size="sm", c="dimmed"),
                        html.Div(id="iconify-out", style={"minHeight": 64}),
                        dmc.Text(id="iconify-name", size="sm", fw=500),
                    ],
                    withBorder=True,
                    p="lg",
                    radius="md",
                    style={"minWidth": 200, "textAlign": "center"},
                ),
            ],
            align="flex-start",
            gap="xl",
        ),
    ],
    gap="md",
)


@callback(
    Output("iconify-picker", "children"),
    Output("iconify-count", "children"),
    Input("iconify-set", "value"),
)
def swap_set(prefix):
    categories = SETS.get(prefix, [])
    if not categories:
        return (
            dmc.Alert(
                "Iconify is unreachable, so this set could not be loaded.",
                color="orange",
            ),
            "",
        )

    total = sum(len(c["emojis"]) for c in categories)
    summary = (
        f"{len(categories)} Iconify categories · {total} icons "
        "(capped at 48 per category). emoji-mart's own categories are still "
        "here, before these — the picker jumps you past them."
    )

    picker = DashEmojiMart(
        id="iconify-emoji-picker",
        custom=categories,
        # `categories` is deliberately NOT passed alongside `custom`.
        #
        # emoji-mart filters that prop against its `originalCategories` array,
        # which it captures BY REFERENCE on the first init in the page's
        # lifetime. Custom categories pushed during that first init land in it
        # through the aliasing, so the pair appears to work. Every later init
        # rebuilds `categories` as a NEW array, breaking the alias — so from the
        # second picker onwards, `categories` filters against a stale list that
        # cannot contain the custom ids, and every custom category is dropped.
        #
        # Symptom when you get this wrong: the first set renders, and every set
        # you switch to afterwards renders an empty picker.
        #
        # Omitting it skips that filter entirely, so the Iconify categories
        # simply join emoji-mart's built-in ones. Scroll the nav bar along to
        # reach them.
        perLine=9,
        emojiSize=24,
        searchPosition="static",
        previewPosition="none",
    )
    # New custom data needs a fresh mount, the same as `set` and `locale`. Dash keys each
    # child on its id, so varying the wrapper's id is what makes React remount rather than
    # patch — a `key=` prop is not read for reconciliation. See docs/configuration.
    return html.Div(picker, id=f"iconify-mount-{prefix}"), summary


@callback(
    Output("iconify-out", "children"),
    Output("iconify-name", "children"),
    Input("iconify-emoji-picker", "value"),
    Input("iconify-emoji-picker", "selectedEmoji"),
)
def show(value, emoji_obj):
    """Render the pick — which is NOT always an Iconify icon.

    emoji-mart's own categories are still in this picker (see the note on the
    page about why `categories` cannot filter them out here), so a reader can
    perfectly well click 🆑 from Symbols. `value` is then the native glyph, not
    a URL, and this callback used to hand it to `html.Img(src=...)` regardless
    — producing `<img src="🆑">`, i.e. a broken-image placeholder next to a
    correct name.

    Same branch as the custom-emojis page: only a custom emoji has a URL.
    """
    if not value:
        return dmc.Text("Nothing yet", c="dimmed", size="lg"), ""
    if value.startswith("http"):
        glyph = html.Img(src=value, style={"height": 56, "width": 56})
    else:
        glyph = dmc.Text(value, style={"fontSize": 56, "lineHeight": 1})
    return glyph, (emoji_obj or {}).get("name", "")


# ---------------------------------------------------------------------------
# Scroll to the Iconify categories after a set is chosen.
#
# emoji-mart appends custom categories AFTER its own nine, and `categories`
# cannot be used to hide those (see the long note on the picker above — it
# works for the first set and empties the picker for every set you switch to
# afterwards; both variants were measured). So choosing "OpenMoji" left the
# reader looking at Smileys & People with roughly five thousand built-in emoji
# between them and the thing they just asked for.
#
# Rather than fight emoji-mart's data model, jump to the section. This is the
# picker's own affordance: the last button in its nav bar is the custom-category
# group, and clicking it is exactly what a reader would do if they knew it was
# there.
#
# Clientside because it is pure DOM work that must happen after the remount
# paints, and it reaches into the shadow root because that is where the picker
# lives — nothing about it can be expressed as a Dash prop. Fail-silent by
# design: if emoji-mart ever renames its nav, the demo simply behaves as it did
# before rather than erroring.
clientside_callback(
    """
    function (summary) {
        const jump = (tries) => {
            const host = document.querySelector('#iconify-picker em-emoji-picker');
            const nav = host && host.shadowRoot && host.shadowRoot.querySelector('nav');
            if (!nav) {
                if (tries > 0) { setTimeout(() => jump(tries - 1), 120); }
                return;
            }
            const buttons = nav.querySelectorAll('button');
            if (buttons.length) { buttons[buttons.length - 1].click(); }
        };
        jump(25);
        return window.dash_clientside.no_update;
    }
    """,
    Output("iconify-count", "children", allow_duplicate=True),
    Input("iconify-count", "children"),
    prevent_initial_call=True,
)
```


---

*Source: /iconify*

---

<!-- /popover — https://emojimart.2plot.dev/popover/llms.txt -->

# Picker in a Popover

> The pattern most apps actually want — a trigger button that opens the picker, and closes it on pick or on an outside click. One callback.

---



### Overview

Very few apps want a 400px picker sitting in the page. The usual shape is a small trigger
— a button, an avatar, a reaction affordance — that opens the picker, takes one pick, and
closes.

`dmc.Popover` gets you all of that except the last step. It opens itself when the target
is clicked and closes itself on an outside click or on `Escape`, so the only thing left to
write is *close after a pick* — one callback.

### Live demo


### The wiring

One callback, one `opened` state:

```python
import dash_mantine_components as dmc
from dash import Input, Output, callback
from dash.exceptions import PreventUpdate
from dash_emoji_mart import DashEmojiMart

layout = dmc.Popover(
    [
        dmc.PopoverTarget(dmc.Button("Add reaction", id="trigger")),
        dmc.PopoverDropdown(DashEmojiMart(id="picker", previewPosition="none")),
    ],
    id="popover",
    opened=False,
    position="bottom-start",
    withArrow=True,
)

# Opening is client-side — dmc.Popover does it. This closes it after a pick.
@callback(
    Output("out", "children"),
    Output("popover", "opened"),
    Output("picker", "value"),
    Input("picker", "value"),
    prevent_initial_call=True,
)
def close_on_pick(value):
    if not value:      # our own reset landing back here — see below
        raise PreventUpdate
    return value, False, None
```


    Closing the popover does not reset the picker — unmounting it does not clear the prop
    in Dash's store. So picking 🎉 twice in a row writes the same `value` the second time,
    Dash sees no change, and no callback fires: the popover just sits there open.

    Writing `None` back to `value` makes every pick a change. The `PreventUpdate` guard is
    what keeps that write from re-entering the callback and blanking the reaction it just
    set.


    It is the one mistake that makes this whole pattern look broken, and the symptom is
    a trigger that appears to do nothing at all. Details below.

### Why there is no toggle callback

`dmc.Popover` wraps whatever you hand `PopoverTarget` in a Box carrying its own click
handler, so opening happens client-side before any callback runs:

```javascript
// dash_mantine_components — Popover
if (type === "PopoverTarget")
    return <Popover.Target>
             <Box onClick={() => setProps({opened: !opened})}>{child}</Box>
           </Popover.Target>
```

A `toggle` callback on the trigger's `n_clicks` that reads `State("popover", "opened")`
therefore sees the *already flipped* `True`, returns `not True` → `False`, and shuts the
popover in the same round trip that opened it.

Closing is covered too. Mantine's `closeOnClickOutside` and `closeOnEscape` both route
through the Popover's `onChange`, which DMC wires to `setProps({opened: False})` — so a
dismissal without a pick needs no Python either.


    `dmc.HoverCard` is hover-triggered, so it closes the moment the pointer leaves the
    trigger on its way to the picker. `dmc.Popover` supports click triggering and a
    controlled `opened`, with the same styling.


    With `keepMounted=True` every picker in the page mounts on load — a full emoji
    dataset and ~1500 buttons each — for popovers the reader may never open. On the
    three-row demo above that is three pickers built up front instead of zero.

### Outside clicks without dmc.Popover

`clickedOutside` is a counter the picker bumps whenever a click lands outside its own
wrapper. Inside a `dmc.Popover` you do not need it — Mantine's own outside-click
handling already fires. It is for the case where you rolled your own panel:

```python
layout = html.Div(
    [
        dmc.Button("Add reaction", id="trigger"),
        dmc.Collapse(DashEmojiMart(id="picker"), id="panel", opened=False),
    ]
)

@callback(
    Output("panel", "opened", allow_duplicate=True),
    Input("picker", "clickedOutside"),
    prevent_initial_call=True,
)
def close_on_outside(_n):
    return False
```


    Add it to every writer past the first — a close-on-pick and a close-on-outside
    callback both targeting `opened`, say. Each one also needs
    `prevent_initial_call=True` alongside it.

### Trimming the picker down

In a popover the picker is the whole surface, so the chrome that helps in a full-page
layout mostly gets in the way:

```python
DashEmojiMart(
    id="picker",
    previewPosition="none",     # drop the 40px preview footer
    skinTonePosition="search",  # tuck the tone selector into the search bar
    maxFrequentRows=1,
    perLine=8,
    emojiSize=22,
)
```

### Reaction bars

For a per-row reaction picker, give each picker a pattern-matching id and let one `MATCH`
callback serve all of them — see [Callbacks & props](/callbacks#pattern-matching-ids). The
demo above does this for its three message rows, and adding a fourth row needs no new
callback code.

### Source


```python
# File: docs/popover/example.py

"""A reaction bar: three message rows, each with its own popover-mounted picker.

Every picker shares one set of pattern-matching callbacks via MATCH, so adding a fourth
row would need no new callback code.

There is deliberately NO callback on the trigger. `dmc.Popover` wraps whatever you put
in `PopoverTarget` in a Box with its own `onClick` that writes `opened = not opened`, so
opening is already handled client-side — a Python `toggle` callback on top of that reads
the ALREADY-flipped `opened` as State and flips it straight back, and the popover never
opens. Closing on pick is the only thing left to wire up.
"""

import dash_mantine_components as dmc
from dash import MATCH, Input, Output, callback
from dash.exceptions import PreventUpdate
from dash_iconify import DashIconify

from dash_emoji_mart import DashEmojiMart

MESSAGES = [
    (1, "Ada", "Shipped the 0.2.0 tag — CI is green across the whole Dash 4 matrix."),
    (2, "Grace", "Docs site is live at emojimart.2plot.dev."),
    (3, "Alan", "The popover pattern is the one people keep asking for."),
]


def reaction_row(index, author, text):
    return dmc.Paper(
        dmc.Group(
            [
                dmc.Stack(
                    [
                        dmc.Text(author, fw=600, size="sm"),
                        dmc.Text(text, size="sm", c="dimmed"),
                    ],
                    gap=2,
                    style={"flex": 1},
                ),
                dmc.Group(
                    [
                        dmc.Text(
                            id={"type": "reaction-out", "index": index},
                            children="",
                            style={"fontSize": 24, "minWidth": 32},
                        ),
                        dmc.Popover(
                            [
                                dmc.PopoverTarget(
                                    dmc.ActionIcon(
                                        DashIconify(
                                            icon="tabler:mood-plus", width=18
                                        ),
                                        id={"type": "reaction-trigger", "index": index},
                                        variant="light",
                                        size="lg",
                                    )
                                ),
                                dmc.PopoverDropdown(
                                    DashEmojiMart(
                                        id={"type": "reaction-picker", "index": index},
                                        perLine=8,
                                        emojiSize=22,
                                        maxFrequentRows=1,
                                        previewPosition="none",
                                        skinTonePosition="search",
                                    ),
                                    p=0,
                                ),
                            ],
                            id={"type": "reaction-popover", "index": index},
                            opened=False,
                            position="bottom-end",
                            withArrow=True,
                            shadow="md",
                            # Explicit, not decorative. keepMounted=True would
                            # mount all three pickers — each one a full emoji
                            # dataset and ~1500 buttons — on page load, for
                            # three popovers the reader may never open.
                            keepMounted=False,
                        ),
                    ],
                    gap="sm",
                ),
            ],
            justify="space-between",
            wrap="nowrap",
        ),
        withBorder=True,
        p="md",
        radius="md",
    )


component = dmc.Stack(
    [reaction_row(i, author, text) for i, author, text in MESSAGES],
    gap="sm",
)


@callback(
    Output({"type": "reaction-out", "index": MATCH}, "children"),
    Output({"type": "reaction-popover", "index": MATCH}, "opened"),
    Output({"type": "reaction-picker", "index": MATCH}, "value"),
    Input({"type": "reaction-picker", "index": MATCH}, "value"),
    prevent_initial_call=True,
)
def pick(value):
    """Set the reaction, close the popover, and clear the picker's `value`.

    Dismissing without picking needs no callback at all — that is Mantine's own
    closeOnClickOutside/closeOnEscape, routed through the Popover's onChange into
    setProps({opened: False}).

    The third output is what makes picking the SAME emoji twice work. `value` is not
    reset by closing the popover (unmounting the picker does not clear the prop in
    Dash's store), so a second identical pick writes an unchanged `value`, Dash sees
    no change, and nothing fires — the popover just sits there open. Clearing it here
    means the next pick is always a change. The guard below is what stops that clearing
    write from re-entering this callback and blanking the reaction we just set.
    """
    if not value:
        raise PreventUpdate
    return value, False, None
```


---

*Source: /popover*

---

<!-- /theming — https://emojimart.2plot.dev/theming/llms.txt -->

# Theming

> Light, dark and auto — and how to make the picker follow your app's colour scheme instead of the OS.

---



### Overview

`theme` takes three values:

| Value | Behaviour |
|-------|-----------|
| `"auto"` (default) | Follows the OS/browser `prefers-color-scheme` setting. |
| `"light"` | Always light, whatever the OS says. |
| `"dark"` | Always dark, whatever the OS says. |

`"auto"` is the right default for a standalone picker, and the wrong one for any app with
its own theme toggle: the OS preference and the app's toggle disagree the moment a user
flips the toggle. Drive `theme` from your own state instead.

### Live demo


### Following a Mantine colour scheme

The cheapest way to keep the picker in step with a `dmc.MantineProvider` is a clientside
callback that maps the scheme onto `theme` — no server round-trip, so the picker flips in
the same frame as the rest of the page:

```python
from dash import Input, Output, clientside_callback

clientside_callback(
    "(scheme) => scheme || 'light'",
    Output("picker", "theme"),
    Input("color-scheme-storage", "data"),
)
```

This documentation site does exactly that — the sun/moon toggle in the header drives every
picker on every page.


    Unlike `set` and `locale`, `theme` is read on every render. Updating it on a mounted
    picker works, and keeps the user's search text and scroll position intact.

### Styling the picker itself

emoji-mart exposes its own CSS custom properties, which you can set on any ancestor —
including through `style` on the component:

```python
DashEmojiMart(
    id="picker",
    style={
        "--em-rgb-accent": "250, 176, 5",
        "--em-rgb-background": "255, 255, 255",
        "--em-rgb-input": "245, 245, 245",
        "--em-rgb-color": "34, 36, 39",
    },
)
```

`emojiButtonColors` cycles hover backgrounds through a list instead:

```python
DashEmojiMart(
    id="picker",
    emojiButtonColors=[
        "rgba(155,223,88,.7)",
        "rgba(149,211,254,.7)",
        "rgba(247,233,34,.7)",
    ],
)
```

### Source


```python
# File: docs/theming/example.py

"""Three theme modes side by side, plus a picker bound to this site's own toggle.

The right-hand picker has no `theme` control of its own: a clientside callback maps the
site's stored colour scheme onto its `theme` prop, so the sun/moon button in the header
drives it. That is the pattern to copy for an app with its own theme switch — `theme` is
re-read on every render, so no remount is needed.
"""

import dash_mantine_components as dmc
from dash import Input, Output, callback, clientside_callback, html

from dash_emoji_mart import DashEmojiMart

component = dmc.Stack(
    [
        dmc.SegmentedControl(
            id="theme-mode",
            data=[
                {"value": "light", "label": "light"},
                {"value": "dark", "label": "dark"},
                {"value": "auto", "label": "auto"},
            ],
            value="light",
        ),
        dmc.SimpleGrid(
            [
                dmc.Stack(
                    [
                        dmc.Text("Explicit theme", fw=600, size="sm"),
                        DashEmojiMart(
                            id="theme-explicit-picker",
                            theme="light",
                            perLine=8,
                            emojiSize=22,
                            maxFrequentRows=1,
                            previewPosition="none",
                        ),
                        dmc.Text(id="theme-explicit-out", size="xl"),
                    ],
                    gap="xs",
                ),
                dmc.Stack(
                    [
                        dmc.Text("Follows this site's toggle", fw=600, size="sm"),
                        DashEmojiMart(
                            id="theme-synced-picker",
                            perLine=8,
                            emojiSize=22,
                            maxFrequentRows=1,
                            previewPosition="none",
                        ),
                        dmc.Text(
                            "Flip the sun/moon in the header.",
                            size="xs",
                            c="dimmed",
                        ),
                    ],
                    gap="xs",
                ),
            ],
            cols={"base": 1, "md": 2},
            spacing="xl",
        ),
    ],
    gap="md",
)


@callback(Output("theme-explicit-picker", "theme"), Input("theme-mode", "value"))
def set_theme(value):
    return value or "auto"


@callback(Output("theme-explicit-out", "children"), Input("theme-explicit-picker", "value"))
def show(value):
    return value or ""


# The site header stores the active colour scheme in `color-scheme-storage`.
# Clientside so the picker repaints in the same frame as the rest of the page.
clientside_callback(
    "(scheme) => scheme || 'light'",
    Output("theme-synced-picker", "theme"),
    Input("color-scheme-storage", "data"),
)
```


---

*Source: /theming*
