Skip to content

Emoji reactions that pop, roll their counts, say who, and roll back.

Collaborationmotion@base-ui/react@number-flow/react

01Preview

Maya Okafor10:42

New onboarding flow is live for 10% of signups. Activation is up 6 points in the first hour.

Jon Park10:47

Can we hold the rollout to 25% until the billing fix ships?

Online: reactions save in 300ms

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { ReactionBar } from "@/components/ui/reaction-bar";

<ReactionBar
  defaultValue={[
    { emoji: "🎉", count: 5, reacted: true, people: ["Jon Park", "Lena Fischer"] },
    { emoji: "👀", count: 2, people: ["Jon Park", "Marcus Webb"] },
  ]}
  onReact={(emoji, reacted) => api.react(messageId, emoji, reacted)}
/>

04Source

"use client";
import { Popover } from "@base-ui/react/popover";
import { Tooltip } from "@base-ui/react/tooltip";
import NumberFlow from "@number-flow/react";
import {
  AnimatePresence,
  motion,
  useAnimate,
  useReducedMotion,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type Reaction = {
  emoji: string;
  /** Total people who reacted with it, the viewer included. */
  count: number;
  /** Whether the viewer is one of them. */
  reacted?: boolean;
  /** Names of some or all of the people, for the tooltip. The viewer is added and removed for you. */
  people?: string[];
};

export type ReactionChoice = { emoji: string; label: string };

export const defaultChoices: ReactionChoice[] = [
  { emoji: "👍", label: "Thumbs up" },
  { emoji: "❤️", label: "Heart" },
  { emoji: "😂", label: "Laughing" },
  { emoji: "🎉", label: "Party popper" },
  { emoji: "😮", label: "Surprised" },
  { emoji: "🙏", label: "Thank you" },
  { emoji: "👀", label: "Eyes" },
  { emoji: "🔥", label: "Fire" },
  { emoji: "", label: "Done" },
  { emoji: "🚀", label: "Rocket" },
  { emoji: "💯", label: "Hundred" },
  { emoji: "👏", label: "Clapping" },
  { emoji: "🤔", label: "Thinking" },
  { emoji: "😢", label: "Sad" },
  { emoji: "👎", label: "Thumbs down" },
  { emoji: "💡", label: "Idea" },
];

/* -------------------------------------------------------------------------------------------------
 * The reducer: one toggle, used for the optimistic change and for its rollback
 * -----------------------------------------------------------------------------------------------*/

/** Adds or removes the viewer's reaction. A reaction nobody has any more disappears. */
export function toggleReaction(
  list: Reaction[],
  emoji: string,
  you = "You",
): Reaction[] {
  const found = list.find((r) => r.emoji === emoji);
  if (!found)
    return [...list, { emoji, count: 1, reacted: true, people: [you] }];
  return list
    .map((r) => {
      if (r.emoji !== emoji) return r;
      const people = (r.people ?? []).filter((p) => p !== you);
      return r.reacted
        ? { ...r, reacted: false, count: Math.max(0, r.count - 1), people }
        : { ...r, reacted: true, count: r.count + 1, people: [you, ...people] };
    })
    .filter((r) => r.count > 0);
}

const list = new Intl.ListFormat("en", { type: "conjunction" });

// "You, Maya and Jon", "Maya, Jon and 4 others". The viewer always comes first.
function who(r: Reaction, you: string) {
  const names = [
    ...(r.reacted ? [you] : []),
    ...(r.people ?? []).filter((p) => p !== you),
  ];
  const shown = names.slice(0, 3);
  const rest = Math.max(0, r.count - shown.length);
  if (!shown.length) return r.count === 1 ? "1 person" : `${r.count} people`;
  return rest > 0
    ? `${shown.join(", ")} and ${rest} ${rest === 1 ? "other" : "others"}`
    : list.format(shown);
}

/* -------------------------------------------------------------------------------------------------
 * ReactionBar
 * -----------------------------------------------------------------------------------------------*/

type Tip = { title: string; sub?: string; emoji?: string };

export type ReactionBarProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange"
> & {
  value?: Reaction[];
  defaultValue?: Reaction[];
  /** Fires on every change, including a rollback after a failed save. */
  onValueChange?: (next: Reaction[]) => void;
  /** Persist one toggle. Return a promise; if it rejects, that toggle is undone and the bar says so. */
  onReact?: (emoji: string, reacted: boolean) => unknown;
  /** What the picker offers, in order. */
  choices?: ReactionChoice[];
  /** How the viewer appears in the people list. */
  you?: string;
  size?: "sm" | "md";
  /** Shows the reactions without letting anyone change them: archived threads, signed-out views. */
  readOnly?: boolean;
  /** Where the picker and tooltips portal. Defaults to document.body. */
  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
};

/**
 * Reactions under a message. Yours pops and its count rolls; a new one springs into the row
 * while its neighbors make room; the tooltip says who. Saves are optimistic and roll back.
 */
export function ReactionBar({
  value: valueProp,
  defaultValue = [],
  onValueChange,
  onReact,
  choices = defaultChoices,
  you = "You",
  size = "md",
  readOnly = false,
  container,
  className,
  ...rest
}: ReactionBarProps) {
  const reduce = useReducedMotion();
  const [value, setValue] = useControllableState({
    value: valueProp,
    defaultValue,
    onChange: onValueChange,
  });
  const [tip] = useState(() => Tooltip.createHandle<Tip>());
  const [open, setOpen] = useState(false);
  const [error, setError] = useState<{ emoji: string; message: string } | null>(
    null,
  );
  const [bumps, setBumps] = useState<Record<string, number>>({});
  const latest = useRef(value);
  const errorTimer = useRef<number>(undefined);
  useEffect(() => {
    latest.current = value;
  }, [value]);
  useEffect(() => () => window.clearTimeout(errorTimer.current), []);

  const label = (emoji: string) =>
    choices.find((c) => c.emoji === emoji)?.label ?? emoji;

  const react = (emoji: string) => {
    const before = latest.current.find((r) => r.emoji === emoji);
    const adding = !before?.reacted;
    const next = toggleReaction(latest.current, emoji, you);
    latest.current = next;
    setValue(next);
    setError(null);
    if (adding) setBumps((b) => ({ ...b, [emoji]: (b[emoji] ?? 0) + 1 }));
    if (!onReact) return;
    Promise.resolve()
      .then(() => onReact(emoji, adding))
      .catch(() => {
        // Undo just this toggle, on top of whatever else has changed since.
        const undone = toggleReaction(latest.current, emoji, you);
        latest.current = undone;
        setValue(undone);
        setError({
          emoji,
          message: `Couldn’t ${adding ? "add" : "remove"} ${label(emoji).toLowerCase()}`,
        });
        window.clearTimeout(errorTimer.current);
        errorTimer.current = window.setTimeout(() => setError(null), 5000);
      });
  };

  const h = size === "sm" ? "h-6 text-[11.5px]" : "h-7 text-[12px]";
  const chipBase = cn(
    "relative inline-flex items-center gap-1.5 rounded-full border px-2 font-medium tabular outline-none select-none",
    "transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.94] active:duration-75",
    "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
    // Chips draw at 24–28px; on touch each grows a 44px target without moving.
    "before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
    h,
  );

  return (
    <div
      role="group"
      aria-label="Reactions"
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...rest}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {value.map((r) => (
          <motion.span
            key={r.emoji}
            layout={reduce ? false : "position"}
            initial={
              reduce
                ? { opacity: 0 }
                : { opacity: 0, scale: 0.6, filter: "blur(2px)" }
            }
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={
              reduce
                ? { opacity: 0, transition: { duration: 0.1 } }
                : {
                    opacity: 0,
                    scale: 0.6,
                    filter: "blur(2px)",
                    transition: { duration: 0.14, ease: ease.out },
                  }
            }
            transition={
              reduce
                ? { duration: 0.15 }
                : { ...spring.pop, layout: spring.snappy }
            }
            className="flex"
          >
            <Tooltip.Trigger
              handle={tip}
              payload={{
                title: who(r, you),
                sub: `reacted with ${label(r.emoji).toLowerCase()}`,
                emoji: r.emoji,
              }}
              delay={400}
              disabled={open}
              render={
                <Chip
                  reaction={r}
                  label={label(r.emoji)}
                  bump={bumps[r.emoji] ?? 0}
                  failed={error?.emoji === r.emoji}
                  readOnly={readOnly}
                  className={chipBase}
                  onClick={() => react(r.emoji)}
                />
              }
            />
          </motion.span>
        ))}

        {!readOnly && (
          <motion.span
            key="add"
            layout={reduce ? false : "position"}
            transition={spring.snappy}
            className="flex"
          >
            <Popover.Root open={open} onOpenChange={setOpen}>
              <Popover.Trigger
                aria-label="Add reaction"
                className={cn(
                  chipBase,
                  "group/add justify-center border-line-2 bg-raised text-fg-3 hover:border-fg-4 hover:bg-hover hover:text-fg data-popup-open:border-fg-4 data-popup-open:bg-hover data-popup-open:text-fg",
                  size === "sm" ? "w-8 px-0" : "w-9 px-0",
                )}
              >
                <Tooltip.Trigger
                  handle={tip}
                  payload={{ title: "Add reaction" }}
                  delay={400}
                  disabled={open}
                  render={
                    <span className="grid size-full place-items-center" />
                  }
                >
                  <AddGlyph size={size === "sm" ? 14 : 16} />
                </Tooltip.Trigger>
              </Popover.Trigger>
              <Popover.Portal container={container}>
                <Popover.Positioner
                  side="top"
                  align="start"
                  sideOffset={8}
                  collisionPadding={8}
                  className="z-(--z-popover)"
                >
                  <Popover.Popup
                    className={cn(
                      "rounded-xl border border-line-2 bg-raised p-1.5 text-fg shadow-pop outline-none",
                      "origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-180 ease-out-expo",
                      "data-starting-style:scale-[0.94] data-starting-style:opacity-0 data-starting-style:blur-[2px]",
                      "data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1",
                      "data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-120 data-ending-style:ease-out-quart",
                      "data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:blur-none",
                    )}
                  >
                    <Popover.Title className="sr-only">
                      Add reaction
                    </Popover.Title>
                    <Picker
                      choices={choices}
                      mine={
                        new Set(
                          value.filter((r) => r.reacted).map((r) => r.emoji),
                        )
                      }
                      onPick={(emoji) => {
                        react(emoji);
                        setOpen(false);
                      }}
                    />
                  </Popover.Popup>
                </Popover.Positioner>
              </Popover.Portal>
            </Popover.Root>
          </motion.span>
        )}
      </AnimatePresence>

      <AnimatePresence initial={false}>
        {error && (
          // Its own line, opened to height, so the thread below eases down instead of jumping.
          <motion.span
            key="error"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{
              height: 0,
              opacity: 0,
              transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut },
            }}
            transition={
              reduce
                ? { duration: 0 }
                : {
                    height: { duration: 0.22, ease: ease.inOut },
                    opacity: { duration: 0.18, ease: ease.out },
                  }
            }
            className="-mt-1.5 basis-full overflow-hidden"
          >
            <span className="flex items-center gap-1.5 pl-1 pt-1.5 text-[12px] text-danger">
              {error.message}
              <button
                type="button"
                onClick={() => react(error.emoji)}
                className="rounded-sm font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3"
              >
                Try again
              </button>
            </span>
          </motion.span>
        )}
      </AnimatePresence>
      <span role="status" aria-live="polite" className="sr-only">
        {error ? `${error.message}. Try again.` : ""}
      </span>

      {/* One tooltip for the row. It glides between chips instead of closing and reopening. */}
      <Tooltip.Root handle={tip}>
        {({ payload }) => (
          <Tooltip.Portal container={container}>
            <Tooltip.Positioner
              side="top"
              sideOffset={8}
              collisionPadding={8}
              className="z-(--z-tooltip) h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-200 ease-out-quart data-instant:transition-none"
            >
              <Tooltip.Popup
                className={cn(
                  "h-(--popup-height,auto) w-(--popup-width,auto) overflow-clip rounded-lg border border-line-2 bg-raised text-[12px] leading-4 text-fg shadow-pop outline-none",
                  "origin-(--transform-origin) transition-[opacity,scale,width,height] [transition-duration:150ms,150ms,200ms,200ms] ease-out-expo",
                  "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:100ms]",
                  "data-instant:transition-none motion-reduce:data-starting-style:scale-100",
                )}
              >
                <Tooltip.Viewport
                  className={cn(
                    "relative h-full w-full",
                    "[&>*]:flex [&>*]:w-max [&>*]:max-w-[15rem] [&>*]:items-center [&>*]:gap-2 [&>*]:px-2 [&>*]:py-[5px]",
                    "[&>[data-previous]]:absolute [&>[data-previous]]:left-0 [&>[data-previous]]:top-0",
                    "[&>*]:transition-[opacity,filter] [&>*]:duration-150",
                    "[&>[data-current][data-starting-style]]:opacity-0 [&>[data-current][data-starting-style]]:blur-[2px]",
                    "[&>[data-previous][data-ending-style]]:opacity-0 [&>[data-previous]]:duration-100",
                  )}
                >
                  {payload && (
                    <>
                      {payload.emoji && (
                        <span className="shrink-0 text-[20px] leading-none">
                          {payload.emoji}
                        </span>
                      )}
                      <span className="flex min-w-0 flex-col">
                        <span className="text-pretty">{payload.title}</span>
                        {payload.sub && (
                          <span className="text-fg-3">{payload.sub}</span>
                        )}
                      </span>
                    </>
                  )}
                </Tooltip.Viewport>
              </Tooltip.Popup>
            </Tooltip.Positioner>
          </Tooltip.Portal>
        )}
      </Tooltip.Root>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Chip
 * -----------------------------------------------------------------------------------------------*/

type ChipProps = Omit<React.ComponentProps<"button">, "children"> & {
  reaction: Reaction;
  label: string;
  /** Increments each time the viewer adds this reaction; each increment pops the emoji once. */
  bump: number;
  failed: boolean;
  readOnly: boolean;
};

function Chip({
  reaction: r,
  label,
  bump,
  failed,
  readOnly,
  className,
  ...rest
}: ChipProps) {
  const reduce = useReducedMotion();
  const [scope, animate] = useAnimate<HTMLSpanElement>();
  const seen = useRef(bump);

  useEffect(() => {
    if (bump === seen.current) return;
    seen.current = bump;
    if (reduce || !scope.current) return;
    // Up, a little past, settle: the emoji takes the press like something soft.
    animate(
      scope.current,
      { scale: [1, 1.38, 0.92, 1], rotate: [0, -8, 4, 0] },
      { duration: 0.46, ease: ease.out, times: [0, 0.3, 0.65, 1] },
    );
  }, [bump, reduce, animate, scope]);

  const count = `${r.count} ${r.count === 1 ? "reaction" : "reactions"}`;
  return (
    <button
      type="button"
      aria-pressed={r.reacted ?? false}
      aria-label={`${label}, ${count}${r.reacted ? ", including you" : ""}`}
      aria-disabled={readOnly || undefined}
      data-reacted={r.reacted || undefined}
      data-failed={failed || undefined}
      className={cn(
        className,
        "border-line-2 bg-raised text-fg-2 hover:border-fg-4 hover:bg-hover hover:text-fg",
        "data-reacted:border-fg/30 data-reacted:bg-fg/[0.08] data-reacted:text-fg data-reacted:hover:bg-fg/[0.12]",
        "data-failed:border-danger/40 data-failed:text-danger",
        // Read-only chips still take focus and hover, so the names stay reachable; they just don't press.
        readOnly &&
          "cursor-default active:scale-100 hover:border-line-2 hover:bg-raised data-reacted:hover:bg-fg/[0.08]",
      )}
      {...rest}
      onClick={readOnly ? undefined : rest.onClick}
    >
      <span
        ref={scope}
        aria-hidden
        className="inline-block text-[1.2em] leading-none"
      >
        {r.emoji}
      </span>
      <NumberFlow value={r.count} aria-hidden className="leading-none" />
    </button>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Picker: a grid with arrow keys, one tab stop
 * -----------------------------------------------------------------------------------------------*/

const COLUMNS = 8;

function Picker({
  choices,
  mine,
  onPick,
}: {
  choices: ReactionChoice[];
  mine: Set<string>;
  onPick: (emoji: string) => void;
}) {
  const [active, setActive] = useState(0);
  const grid = useRef<HTMLDivElement>(null);

  const move = (e: React.KeyboardEvent) => {
    const last = choices.length - 1;
    const next =
      e.key === "ArrowRight"
        ? Math.min(last, active + 1)
        : e.key === "ArrowLeft"
          ? Math.max(0, active - 1)
          : e.key === "ArrowDown"
            ? Math.min(last, active + COLUMNS)
            : e.key === "ArrowUp"
              ? Math.max(0, active - COLUMNS)
              : e.key === "Home"
                ? 0
                : e.key === "End"
                  ? last
                  : -1;
    if (next < 0) return;
    e.preventDefault();
    setActive(next);
    grid.current
      ?.querySelectorAll<HTMLElement>("[role=gridcell]")
      [next]?.focus();
  };

  const rows = Array.from(
    { length: Math.ceil(choices.length / COLUMNS) },
    (_, i) => choices.slice(i * COLUMNS, i * COLUMNS + COLUMNS),
  );
  return (
    <div
      ref={grid}
      role="grid"
      aria-label="Reactions"
      onKeyDown={move}
      className="flex flex-col gap-0.5"
    >
      {rows.map((row, ri) => (
        <div key={ri} role="row" className="flex gap-0.5">
          {row.map((c, ci) => {
            const i = ri * COLUMNS + ci;
            const on = mine.has(c.emoji);
            return (
              <button
                key={c.emoji}
                type="button"
                role="gridcell"
                aria-label={c.label}
                aria-selected={on}
                tabIndex={i === active ? 0 : -1}
                onFocus={() => setActive(i)}
                onClick={() => onPick(c.emoji)}
                className={cn(
                  "group/cell grid size-8 place-items-center rounded-lg outline-none",
                  "transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.9] active:duration-75",
                  "focus-visible:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4",
                  on &&
                    "bg-fg/[0.08] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_22%,transparent)]",
                )}
              >
                <span
                  aria-hidden
                  className="text-[18px] leading-none transition-transform duration-200 ease-out-expo group-hover/cell:scale-[1.18] group-focus-visible/cell:scale-[1.18] motion-reduce:transition-none motion-reduce:group-hover/cell:scale-100"
                >
                  {c.emoji}
                </span>
              </button>
            );
          })}
        </div>
      ))}
    </div>
  );
}

function AddGlyph({ size }: { size: number }) {
  // A face with a plus where its shoulder would be: "add a reaction", on the 16px grid.
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.4}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <path d="M13.2 8.6A5.75 5.75 0 1 1 7.4 2.3" />
      <path d="M5.6 9.5c.55.8 1.4 1.25 2.4 1.25s1.85-.45 2.4-1.25" />
      <circle cx="5.9" cy="6.6" r=".6" fill="currentColor" stroke="none" />
      <circle cx="10.1" cy="6.6" r=".6" fill="currentColor" stroke="none" />
      <path
        d="M12.25 1.75v3.5M10.5 3.5H14"
        className="transition-transform duration-200 ease-out-expo [transform-box:fill-box] [transform-origin:center] group-hover/add:rotate-90 motion-reduce:transition-none"
      />
    </svg>
  );
}

05Props

ReactionBar

PropTypeDefaultDescription
valueReaction[]Controlled reactions: { emoji, count, reacted?, people? }. Pair with onValueChange.
defaultValueReaction[][]Initial reactions when uncontrolled.
onValueChange(next: Reaction[]) => voidFires on every change, including a rollback after a failed save.
onReact(emoji: string, reacted: boolean) => unknownPersist one toggle. If the returned promise rejects, just that toggle is undone and an inline error offers Try again.
choices{ emoji: string; label: string }[]16 common reactionsWhat the picker offers, in order. Labels name the chips for screen readers and tooltips.
youstring"You"How the viewer appears in the people list. Always listed first.
size"sm" | "md""md"24px or 28px chips.
readOnlybooleanfalseChips still focus and show who reacted, but can’t be pressed, and there is no add button.
containerHTMLElement | RefObject | nullWhere the picker and tooltips portal. Defaults to document.body.

toggleReaction

PropTypeDefaultDescription
(list, emoji, you?)Reaction[]The reducer the bar uses: adds or removes the viewer, drops a reaction at zero. Use it to apply the same change from a realtime event.

06Notes

Behavior

  • Toggling is optimistic. A failure undoes only that toggle on top of whatever changed since, rolls the count back, tints the chip and opens a line saying what failed, with Try again.
  • A reaction that drops to zero leaves and its neighbors close up; a new one from the picker springs into the row before the add button.
  • Picking an emoji you already used from the picker removes it, and the picker marks the ones that are yours.
  • The tooltip lists up to three names, you first, then “and 4 others” from the count, so it stays right when the server sends only a few names.

Motion

  • Adding yours pops the emoji up to 1.38×, a little past, and back with a small rotation over 460ms; the count rolls with NumberFlow instead of swapping.
  • Chips enter from 0.6 scale with a 2px blur on the pop spring; neighbors slide on the snappy spring with position-only layout, so text never stretches.
  • Picker grows from the add button in 180ms and closes in 120ms; hovered emoji scale to 1.18. The error line opens its height in 220ms. Reduced motion keeps short fades and drops the pop, blur, scale and height animation.

Accessibility

  • Each chip is a toggle button named with the reaction and its count (“Eyes, 2 reactions, including you”), with aria-pressed for yours.
  • The picker is a grid with one tab stop: arrow keys move by cell and row, Home and End jump, Enter picks and closes, and focus returns to the add button.
  • Failures are announced through a polite live region. One tooltip glides between chips instead of reopening.