Feedback
Emoji Reaction
A reaction bar of tallied emoji chips plus a keyboard-navigable picker for adding one more.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/emoji-reaction.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "EmojiReaction" component using
class-variance-authority (cva) for variants and lucide-react for the plus
icon. No dropdown library — the picker is hand-built.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>
(omit onSelect) plus VariantProps of the cva config.
- Props: reactions: { emoji: string; count: number; reacted: boolean }[];
onReact: (emoji: string) => void; palette?: string[] (default a set of
common emoji); max?: number; size = "sm" | "md" | "lg" (default "md");
className merged last via cn(). Export the Reaction type too.
- Fully controlled: the component never mutates counts. One callback,
onReact(emoji), covers both toggling an existing chip and picking a new
emoji — the consumer decides whether that is an add, a removal, or a no-op,
and re-renders with new props.
Behavior
- Chips: reactions.slice(0, max) render as real <button type="button"> with
aria-pressed={reacted} and an aria-label like "👍 3 reactions, you reacted"
(singular/plural handled). The emoji glyph sits in a
<span role="img" aria-hidden> so the label is announced once, not twice.
- Overflow: anything past max collapses into a plain "+N" text tail with an
sr-only " more reactions" — deliberately not a button, because it has no
action attached.
- Pop feedback: clicking a chip runs one scale bounce (1 -> 1.28 -> 0.94 -> 1,
~320ms) on the chip's inner content. Track { emoji, seq } in state and key
the inner span by seq so rapid re-clicks restart the animation without
remounting the button (which would drop focus); clear the state in
onAnimationEnd — no timers to leak. onAnimationEnd only fires if a chip for
that emoji is actually rendered, so also clear the state whenever the popped
emoji has no visible chip (picked past max, or the consumer never added /
already removed it); otherwise the stale pop sits there and fires as an
unrequested bounce the next time that emoji appears. Skip spawning the bounce
entirely when
window.matchMedia("(prefers-reduced-motion: reduce)").matches. Counts change
immediately either way — the animation is never the feedback channel.
- Picker: a "+" button with aria-expanded / aria-haspopup / aria-label="Add
reaction" toggles an absolutely positioned panel (role="menu") holding the
palette as role="menuitemcheckbox" buttons with aria-checked reflecting
whether that emoji is already in reactions with reacted=true, plus a
primary-tinted ring on the selected ones.
- Picker keyboard + dismissal: on open, focus the first item; Arrow
Right/Down and Left/Up wrap through items, Home/End jump to the ends
(items carry tabIndex={-1} and are focused programmatically); Escape closes
and returns focus to the trigger; a document "pointerdown" listener closes
it when the press lands outside the popover wrapper. Register that listener
only while open and remove it in the effect cleanup. Tab must close it too:
a focusout on the wrapper (React's onBlur, which bubbles) whose relatedTarget
is no longer inside the wrapper closes the panel without moving focus back —
a role="menu" may not survive the focus that left it, and since every item is
tabIndex={-1} an open panel would otherwise be stranded with no Escape
handler in reach. Picking an emoji calls onReact and closes with focus
restored.
- The panel mounts only while open, so nothing is left in the DOM or in the
tab order when closed.
Rendering & styling
- Semantic tokens only. Chip base: rounded-full border, h-6/7/8 by size.
reacted=true is border-primary + bg-primary/10 + text-foreground (my
reaction reads as "filled"); reacted=false is border-border + bg-muted/60 +
text-muted-foreground with a hover lift to bg-muted + text-foreground.
Panel: border + bg-popover + text-popover-foreground + shadow-md;
items hover/focus on bg-accent. Focus-visible rings use ring-ring with an
offset against ring-offset-background.
- Counts use tabular-nums so a chip does not resize as 9 becomes 10.
- Ship the pop and panel-entry @keyframes in one React 19 hoisted
<style href precedence="medium"> tag; the panel entry animation is
motion-reduce:[animation:none].
Customization levers
- Palette: pass any emoji list (product-specific sets like 🚀 🧠 🐛 ✅ read
better than generic faces in dev tools).
- Density: size drives chip height, padding, gap and picker item size; the
whole scale lives in one record.
- Overflow policy: max collapses the tail — drop it to always show every
reaction, or make the "+N" a button that reveals the rest if your product
needs it (then it must get a real handler and aria-expanded).
- Picker placement: the panel is absolutely positioned against a relative
wrapper — flip it to bottom-full/mb-2 when the bar sits at the bottom of a
scroll container.
- Emphasis of "mine": the border-primary + bg-primary/10 pair is the only
signal for reacted state; swap it for a token pair that matches your
selection language, and keep aria-pressed either way.
- Optimistic vs server-confirmed: onReact is a plain callback — apply the
count locally for instant feedback, or await the server and pass the
confirmed tally back down.Concepts
- Reaction tally — the component renders an already-aggregated list (
emoji,count,reacted); aggregation, dedupe and persistence stay in the consumer, which is what makes it drop into an optimistic or a server-confirmed flow unchanged. - Toggle-my-reaction — one callback covers add and remove because the meaningful state is binary per viewer;
aria-pressedis the accessible mirror of that same bit. - Self-drawn popover — the picker is a plain absolutely-positioned panel with a document
pointerdownlistener and an Escape handler, so the component carries no dropdown dependency and stays installable anywhere. - Roving focus — panel items are
tabIndex={-1}and moved by arrow keys, so the panel adds no tab stops of its own (the "+" trigger is the only one); Escape hands focus back to that trigger, and a Tab that carries focus out of the wrapper closes the panel behind it rather than leaving an orphan menu floating over the page. - Animation is never the feedback — the count updates from props the moment the consumer changes it; the bounce is a bonus that reduced-motion users simply do not get.
- Overflow honesty — the collapsed "+N" is text, not a button, because nothing happens when you click it; a fake affordance there is worse than a plain number.