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:
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:
// 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:
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:
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. The demo above does this for its three message rows, and adding a fourth row needs no new callback code.
Source
# 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
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /popover/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt