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…

"use client"

import * as React from "react"
import { Plus } from "lucide-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via a React 19 hoisted <style> — no
 * Tailwind config edits, duplicates dedupe by href.
 */
const KEYFRAMES = `@keyframes er-pop{0%{transform:scale(1)}35%{transform:scale(1.28)}70%{transform:scale(0.94)}100%{transform:scale(1)}}
@keyframes er-panel{from{opacity:0;transform:translateY(-0.25rem) scale(0.96)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/emoji-reaction.json

Prompt

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-pressed is the accessible mirror of that same bit.
  • Self-drawn popover — the picker is a plain absolutely-positioned panel with a document pointerdown listener 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.

On This Page