UI

Emoji reactions

A compact emoji reaction picker that swaps the emoji on your message with a short spring bounce, persistent selection, and keyboard navigation.

Props

Disabled

Installation

Emoji reactions is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/emoji-reactions

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { EmojiReactions } from "@/components/ui/emoji-reactions";
 
export function Message() {
  return (
    <div className="relative max-w-72 rounded-2xl bg-muted p-4">
      <p>A little bounce makes everything better.</p>
      <EmojiReactions className="absolute -bottom-4 right-3" />
    </div>
  );
}

The reaction badge opens a compact picker below the message. Selecting an emoji swaps the reaction on the badge with a short spring bounce. The picker stays open until you click outside, click the badge, or press Escape. Hover only highlights an option. The defaults use native emoji: ❤️ Love, 😂 Laugh, 🔥 Fire, 👑 Crown, and 👍 Like. Emoji appearance follows the device and operating system.

Props

PropTypeDefaultNotes
reactionsEmojiReaction[]Love, Laugh, Fire, Crown, LikeCustom emoji strings and accessible labels. An empty array disables opening.
valuestring | nullUncontrolledSelected reaction id. Null means no reaction. Pair with onValueChange; without it, a controlled picker is disabled.
defaultValuestring | nullnullInitial selection for local state. Later changes do not reset it.
onValueChange(value: string) => voidNoneCalled when a different reaction is selected. Selection does not dismiss the picker.
disabledbooleanfalseDisables the trigger and closes an open picker.
classNamestringNoneExtra classes on the trigger, useful for positioning on a message.

Reaction fields

FieldTypeRequiredNotes
idstringYesUnique, stable, non-empty identifier.
emojistringYesNon-empty emoji displayed in the picker and selected badge.
labelstringYesNon-empty accessible name, such as Love.

Duplicate ids, missing or empty emoji strings, empty ids or labels, and selected ids missing from the list throw descriptive errors. Without a selection, the trigger shows a 🙂 emoji with an Add reaction accessible label. Selecting the current reaction does nothing; reactions are replaced, not toggled off.

Controlled selection

"use client";
 
import { useState } from "react";
import { EmojiReactions, type EmojiReaction } from "@/components/ui/emoji-reactions";
 
const reactions: EmojiReaction[] = [
  { id: "love", emoji: "❤️", label: "Love" },
  { id: "fire", emoji: "🔥", label: "Fire" },
  { id: "like", emoji: "👍", label: "Like" },
];
 
export function MessageReaction() {
  const [reaction, setReaction] = useState<string | null>(null);
  return <EmojiReactions reactions={reactions} value={reaction} onValueChange={setReaction} />;
}

The owner controls the displayed value. External updates, including clearing to null, are reflected immediately. The component stores no messages, counts, or server data. Custom reactions are plain objects with emoji strings.

Keyboard and motion

  • Enter or Space opens the picker and focuses the selected option, or the first option when none is selected.
  • Arrow keys move focus with wrapping; Home and End focus the first and last options. Focus alone does not change the selection. Enter or Space selects.
  • Tab or Escape dismisses the picker and returns focus to the trigger. Clicking another focusable element outside preserves that element's focus.
  • Pointer selection gives the message's reaction badge a short spring bounce. Rapid selection immediately shows the latest reaction.
  • Keyboard selection updates without movement. Reduced motion replaces movement and bounce with brief fades.
  • The popover is portaled and adjusts near viewport edges. Multiple instances have independent state and animation identifiers.

Manual installation

Rather not use the CLI? Everything Emoji reactions needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install motion radix-ui

Source

components/ui/emoji-reactions.tsx207 lines
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { useId, useRef, useState, type KeyboardEvent } from "react";

import { cn } from "@/lib/utils";

export interface EmojiReaction {
  /** Unique, stable identifier for this reaction. */
  id: string;
  /** Emoji displayed in the picker and selected badge. */
  emoji: string;
  /** Accessible name, such as "Love". */
  label: string;
}

export interface EmojiReactionsProps {
  reactions?: EmojiReaction[];
  /** Controlled selection. Null means no reaction. Pair with onValueChange. */
  value?: string | null;
  /** Initial selection for an uncontrolled picker. */
  defaultValue?: string | null;
  onValueChange?: (value: string) => void;
  disabled?: boolean;
  /** Extra classes on the compact trigger. */
  className?: string;
}

const DEFAULT_REACTIONS: EmojiReaction[] = [
  { id: "love", emoji: "❤️", label: "Love" },
  { id: "laugh", emoji: "😂", label: "Laugh" },
  { id: "fire", emoji: "🔥", label: "Fire" },
  { id: "crown", emoji: "👑", label: "Crown" },
  { id: "like", emoji: "👍", label: "Like" },
];

/** A compact emoji reaction picker that animates the selection on its trigger. */
export function EmojiReactions({
  reactions = DEFAULT_REACTIONS,
  value,
  defaultValue = null,
  onValueChange,
  disabled = false,
  className,
}: EmojiReactionsProps) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const [open, setOpen] = useState(false);
  const [focusedId, setFocusedId] = useState<string | null>(null);
  const [keyboardSelection, setKeyboardSelection] = useState(false);
  const buttons = useRef<Array<HTMLButtonElement | null>>([]);
  const reduceMotion = useReducedMotion();
  const instanceId = useId();
  const selectedId = value === undefined ? internalValue : value;
  const ids = new Set<string>();

  for (const reaction of reactions) {
    if (!reaction.id.trim() || ids.has(reaction.id) || typeof reaction.emoji !== "string" || !reaction.emoji.trim() || !reaction.label.trim()) {
      throw new Error("EmojiReactions requires unique, non-empty ids, non-empty emoji strings, and non-empty labels.");
    }
    ids.add(reaction.id);
  }
  if (selectedId !== null && !ids.has(selectedId)) {
    throw new Error(`EmojiReactions value "${selectedId}" does not match a reaction id.`);
  }

  const selected = reactions.find((reaction) => reaction.id === selectedId);
  const unavailable = disabled || reactions.length === 0 || (value !== undefined && !onValueChange);
  // Disabling an open picker closes it without reopening when enabled again.
  if (unavailable && open) setOpen(false);
  const isOpen = open && !unavailable;
  const tabbableId = focusedId && ids.has(focusedId) ? focusedId : selectedId ?? reactions[0]?.id;
  const still = reduceMotion || keyboardSelection;
  const selectionTransition = still
    ? { duration: reduceMotion ? 0.1 : 0 }
    : { type: "spring" as const, duration: 0.28, bounce: 0.25 };

  function selectReaction(id: string, keyboard: boolean) {
    if (unavailable || id === selectedId) return;
    setKeyboardSelection(keyboard);
    if (value === undefined) setInternalValue(id);
    onValueChange?.(id);
  }

  function moveFocus(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    let next: number;
    switch (event.key) {
      case "Tab":
        event.preventDefault();
        setOpen(false);
        return;
      case "ArrowRight":
      case "ArrowDown":
        next = (index + 1) % reactions.length;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = (index - 1 + reactions.length) % reactions.length;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = reactions.length - 1;
        break;
      default:
        return;
    }
    event.preventDefault();
    buttons.current[next]?.focus();
  }

  return (
    <PopoverPrimitive.Root open={isOpen} onOpenChange={setOpen}>
      <PopoverPrimitive.Trigger asChild>
        <motion.button
          type="button"
          disabled={unavailable}
          aria-label={selected ? `Change reaction, ${selected.label} selected` : "Add reaction"}
          whileTap={reduceMotion || unavailable ? undefined : { scale: 0.97 }}
          className={cn(
            "relative inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-background p-2 text-muted-foreground shadow-xs ring-1 ring-inset ring-border outline-none transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50",
            selected && "text-primary",
            className,
          )}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={selectedId ?? "empty"}
              aria-hidden="true"
              initial={{ opacity: 0, y: still ? 0 : 6, scale: still ? 1 : 0.95, rotate: still ? 0 : -10 }}
              animate={{ opacity: 1, y: 0, scale: 1, rotate: 0 }}
              exit={{ opacity: 0, transition: { duration: still ? 0 : 0.08 } }}
              transition={{ ...selectionTransition, opacity: { duration: still && !reduceMotion ? 0 : 0.1 } }}
              className="pointer-events-none absolute inset-0 flex items-center justify-center text-xl leading-none"
            >
              {selected?.emoji ?? "🙂"}
            </motion.span>
          </AnimatePresence>
        </motion.button>
      </PopoverPrimitive.Trigger>

      <PopoverPrimitive.Portal forceMount>
        <AnimatePresence>
          {isOpen && (
            <PopoverPrimitive.Content
              key="picker"
              forceMount
              asChild
              side="bottom"
              align="end"
              sideOffset={8}
              collisionPadding={12}
              aria-label="Choose a reaction"
              onOpenAutoFocus={(event) => {
                event.preventDefault();
                const index = Math.max(0, reactions.findIndex((reaction) => reaction.id === selectedId));
                buttons.current[index]?.focus();
              }}
            >
              <motion.div
                initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.95 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: reduceMotion ? 0 : -4, scale: reduceMotion ? 1 : 0.95, transition: { duration: reduceMotion ? 0.1 : 0.12 } }}
                transition={{ duration: reduceMotion ? 0.1 : 0.18, ease: [0.23, 1, 0.32, 1] }}
                className="z-50 max-w-[min(20rem,var(--radix-popover-content-available-width))] origin-(--radix-popover-content-transform-origin) outline-none data-[state=closed]:pointer-events-none"
              >
                <div role="toolbar" aria-label="Reactions" className="flex flex-wrap justify-center gap-1 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-inset ring-border">
                  {reactions.map((reaction, index) => (
                    <motion.button
                      key={reaction.id}
                      ref={(button) => { buttons.current[index] = button; }}
                      type="button"
                      aria-label={reaction.label}
                      aria-pressed={selectedId === reaction.id}
                      tabIndex={reaction.id === tabbableId ? 0 : -1}
                      onFocus={() => setFocusedId(reaction.id)}
                      onKeyDown={(event) => moveFocus(event, index)}
                      onClick={(event) => selectReaction(reaction.id, event.detail === 0)}
                      whileTap={reduceMotion ? undefined : { scale: 0.97 }}
                      className={cn(
                        "relative flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
                        selectedId === reaction.id && "text-primary hover:text-primary",
                      )}
                    >
                      {selectedId === reaction.id && (
                        <motion.span
                          layoutId={`${instanceId}-reaction`}
                          initial={false}
                          transition={selectionTransition}
                          className="pointer-events-none absolute inset-0 rounded-full bg-muted"
                        />
                      )}
                      <span aria-hidden="true" className="relative text-xl leading-none">{reaction.emoji}</span>
                    </motion.button>
                  ))}
                </div>
                <PopoverPrimitive.Arrow width={10} height={5} className="fill-popover" />
                <span role="status" className="sr-only">{selected ? `${selected.label} selected` : "No reaction selected"}</span>
              </motion.div>
            </PopoverPrimitive.Content>
          )}
        </AnimatePresence>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  );
}