Web InnoventixFreeCode

Comments Reactions

Original · free

comments with emoji reactions

byWeb InnoventixReact + Tailwind
commentreactionscomments
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/comment-reactions.json
comment-reactions.tsx
"use client";

import type { FormEvent, KeyboardEvent } from "react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type ReactionKey = "up" | "heart" | "fire" | "eyes" | "think" | "party";

type Reaction = {
  key: ReactionKey;
  glyph: string;
  label: string;
};

const REACTIONS: readonly Reaction[] = [
  { key: "up", glyph: "👍", label: "Agree" },
  { key: "heart", glyph: "❤️", label: "Love this" },
  { key: "fire", glyph: "🔥", label: "Fire" },
  { key: "eyes", glyph: "👀", label: "Watching" },
  { key: "think", glyph: "🤔", label: "Not sure" },
  { key: "party", glyph: "🎉", label: "Celebrate" },
];

const REACTION_BY_KEY: Record<ReactionKey, Reaction> = REACTIONS.reduce(
  (acc, r) => {
    acc[r.key] = r;
    return acc;
  },
  {} as Record<ReactionKey, Reaction>,
);

type Comment = {
  id: string;
  author: string;
  initials: string;
  role: string;
  posted: string;
  body: string;
  accent: string;
  counts: Partial<Record<ReactionKey, number>>;
  mine: ReactionKey[];
};

const SEED: readonly Comment[] = [
  {
    id: "c1",
    author: "Priya Raghavan",
    initials: "PR",
    role: "Staff engineer · Platform",
    posted: "4 hours ago",
    body: "We shipped the same migration last quarter. The part nobody warns you about is that the read replica lag doubles for about 40 minutes while the new index builds. Budget for it, or your p99 will make the on-call rotation very unhappy.",
    accent: "indigo",
    counts: { up: 24, fire: 6, eyes: 3 },
    mine: ["up"],
  },
  {
    id: "c2",
    author: "Marcus Oyelaran",
    initials: "MO",
    role: "Design systems lead",
    posted: "2 hours ago",
    body: "Counterpoint: the index build is fine if you do it CONCURRENTLY and gate the cutover behind a flag. We ran both schemas side by side for six days and diffed the outputs nightly. Zero customer-visible errors.",
    accent: "emerald",
    counts: { up: 11, think: 5, heart: 2 },
    mine: [],
  },
  {
    id: "c3",
    author: "Dana Whitfield",
    initials: "DW",
    role: "SRE · Reliability",
    posted: "47 minutes ago",
    body: "Both of you are right and that is the annoying part. Concurrent builds remove the lock, not the I/O. Watch disk throughput, not just lock waits — that is where our last incident actually came from.",
    accent: "amber",
    counts: { fire: 9, up: 4 },
    mine: [],
  },
];

const ACCENT_RING: Record<string, string> = {
  indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  violet: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
};

function PlusIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
    >
      <path d="M10 5v10M5 10h10" />
    </svg>
  );
}

function SmileIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
    >
      <circle cx="10" cy="10" r="7.25" />
      <path d="M7 11.5c.7 1.1 1.75 1.65 3 1.65s2.3-.55 3-1.65" />
      <path d="M7.6 7.9h.01M12.4 7.9h.01" strokeWidth={2.2} />
    </svg>
  );
}

type PickerProps = {
  commentId: string;
  mine: ReactionKey[];
  onPick: (key: ReactionKey) => void;
};

function ReactionPicker({ commentId, mine, onPick }: PickerProps) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const menuId = `${commentId}-reaction-menu`;

  const close = useCallback((focusTrigger: boolean) => {
    setOpen(false);
    if (focusTrigger) triggerRef.current?.focus();
  }, []);

  const openAt = useCallback((index: number) => {
    setActive(index);
    setOpen(true);
    window.requestAnimationFrame(() => {
      optionRefs.current[index]?.focus();
    });
  }, []);

  const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      openAt(0);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      openAt(REACTIONS.length - 1);
    }
  };

  const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = REACTIONS.length - 1;
    if (event.key === "Escape") {
      event.preventDefault();
      close(true);
      return;
    }
    if (event.key === "Tab") {
      close(false);
      return;
    }
    let next: number | null = null;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = active === last ? 0 : active + 1;
    if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = active === 0 ? last : active - 1;
    if (event.key === "Home") next = 0;
    if (event.key === "End") next = last;
    if (next !== null) {
      event.preventDefault();
      setActive(next);
      optionRefs.current[next]?.focus();
    }
  };

  return (
    <div
      ref={wrapRef}
      className="relative"
      onBlur={(event) => {
        if (!wrapRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false);
      }}
    >
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        aria-label="Add a reaction"
        onClick={() => (open ? close(true) : openAt(0))}
        onKeyDown={onTriggerKeyDown}
        className="inline-flex h-8 items-center gap-1 rounded-full border border-dashed border-slate-300 px-2.5 text-slate-500 transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-600 dark:text-slate-400 dark:hover:border-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-950"
      >
        <SmileIcon />
        <PlusIcon />
      </button>

      <AnimatePresence>
        {open ? (
          <motion.div
            id={menuId}
            role="menu"
            aria-label="Choose a reaction"
            onKeyDown={onMenuKeyDown}
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }}
            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.97 }}
            transition={{ duration: reduce ? 0.12 : 0.16, ease: [0.22, 1, 0.36, 1] }}
            className="absolute bottom-full left-0 z-20 mb-2 flex gap-0.5 rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
          >
            {REACTIONS.map((reaction, index) => {
              const picked = mine.includes(reaction.key);
              return (
                <button
                  key={reaction.key}
                  ref={(node) => {
                    optionRefs.current[index] = node;
                  }}
                  type="button"
                  role="menuitemcheckbox"
                  aria-checked={picked}
                  tabIndex={index === active ? 0 : -1}
                  onFocus={() => setActive(index)}
                  onClick={() => {
                    onPick(reaction.key);
                    close(true);
                  }}
                  title={reaction.label}
                  className="cmtrx-pop relative grid h-9 w-9 place-items-center rounded-xl text-lg transition-transform hover:scale-110 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800"
                >
                  <span aria-hidden="true">{reaction.glyph}</span>
                  <span className="sr-only">{reaction.label}</span>
                  {picked ? (
                    <span className="absolute bottom-1 h-1 w-1 rounded-full bg-indigo-500 dark:bg-indigo-400" />
                  ) : null}
                </button>
              );
            })}
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

type ChipProps = {
  reaction: Reaction;
  count: number;
  active: boolean;
  onToggle: () => void;
};

function ReactionChip({ reaction, count, active, onToggle }: ChipProps) {
  const reduce = useReducedMotion();
  return (
    <button
      type="button"
      aria-pressed={active}
      onClick={onToggle}
      className={[
        "group inline-flex h-8 select-none items-center gap-1.5 rounded-full border px-2.5 text-sm font-medium tabular-nums transition-colors",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        active
          ? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/50 dark:bg-indigo-500/15 dark:text-indigo-200"
          : "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800",
      ].join(" ")}
    >
      <motion.span
        aria-hidden="true"
        key={`${reaction.key}-${active ? "on" : "off"}`}
        initial={reduce ? false : { scale: 0.6 }}
        animate={{ scale: 1 }}
        transition={{ type: "spring", stiffness: 520, damping: 18 }}
        className="text-base leading-none"
      >
        {reaction.glyph}
      </motion.span>
      <span>{count}</span>
      <span className="sr-only">
        {reaction.label} — {active ? "you reacted, activate to remove" : "activate to react"}
      </span>
    </button>
  );
}

export default function CommentReactions() {
  const [comments, setComments] = useState<Comment[]>(() => SEED.map((c) => ({ ...c })));
  const [draft, setDraft] = useState("");
  const [status, setStatus] = useState("");
  const reduce = useReducedMotion();
  const draftId = useId();

  const toggle = useCallback((commentId: string, key: ReactionKey) => {
    setComments((prev) =>
      prev.map((comment) => {
        if (comment.id !== commentId) return comment;
        const had = comment.mine.includes(key);
        const current = comment.counts[key] ?? 0;
        const nextCount = had ? current - 1 : current + 1;
        const counts: Partial<Record<ReactionKey, number>> = { ...comment.counts };
        if (nextCount <= 0) delete counts[key];
        else counts[key] = nextCount;
        return {
          ...comment,
          counts,
          mine: had ? comment.mine.filter((k) => k !== key) : [...comment.mine, key],
        };
      }),
    );
    setStatus(
      `${REACTION_BY_KEY[key].label} reaction updated`,
    );
  }, []);

  const total = useMemo(
    () =>
      comments.reduce(
        (sum, comment) =>
          sum + Object.values(comment.counts).reduce((a: number, b) => a + (b ?? 0), 0),
        0,
      ),
    [comments],
  );

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const text = draft.trim();
    if (!text) {
      setStatus("Write something before posting.");
      return;
    }
    setComments((prev) => [
      ...prev,
      {
        id: `c${prev.length + 1}-${Date.now()}`,
        author: "You",
        initials: "YO",
        role: "Reader",
        posted: "just now",
        body: text,
        accent: "violet",
        counts: {},
        mine: [],
      },
    ]);
    setDraft("");
    setStatus("Comment posted.");
  };

  return (
    <section className="relative w-full bg-white px-5 py-20 sm:px-8 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes cmtrx-rise {
          0% { transform: translateY(0) scale(0.85); opacity: 0; }
          30% { opacity: 1; }
          100% { transform: translateY(-14px) scale(1.05); opacity: 0; }
        }
        .cmtrx-pop { animation: none; }
        @media (prefers-reduced-motion: no-preference) {
          .cmtrx-pop:hover span[aria-hidden="true"] {
            animation: cmtrx-rise 0.9s ease-out infinite;
          }
        }
        @media (prefers-reduced-motion: reduce) {
          .cmtrx-pop, .cmtrx-pop:hover span[aria-hidden="true"] { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl">
        <header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-6 dark:border-slate-800">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
              Discussion
            </h2>
            <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
              {comments.length} comments · {total} reactions · thread closes in 3 days
            </p>
          </div>
          <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
            Live
          </span>
        </header>

        <ul className="space-y-3">
          <AnimatePresence initial={false}>
            {comments.map((comment) => {
              const keys = REACTIONS.filter((r) => (comment.counts[r.key] ?? 0) > 0);
              return (
                <motion.li
                  key={comment.id}
                  layout={!reduce}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                  className="rounded-2xl border border-slate-200 bg-white p-5 transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/40 dark:hover:border-slate-700"
                >
                  <div className="flex items-start gap-3.5">
                    <span
                      aria-hidden="true"
                      className={`grid h-10 w-10 shrink-0 place-items-center rounded-full text-xs font-semibold ${
                        ACCENT_RING[comment.accent] ?? ACCENT_RING.indigo
                      }`}
                    >
                      {comment.initials}
                    </span>
                    <div className="min-w-0 flex-1">
                      <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
                        <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                          {comment.author}
                        </span>
                        <span className="text-xs text-slate-400 dark:text-slate-500">
                          {comment.role}
                        </span>
                        <span className="text-xs text-slate-400 dark:text-slate-500">
                          · {comment.posted}
                        </span>
                      </div>
                      <p className="mt-2 text-[0.9375rem] leading-relaxed text-slate-700 dark:text-slate-300">
                        {comment.body}
                      </p>

                      <div className="mt-4 flex flex-wrap items-center gap-1.5">
                        {keys.map((reaction) => (
                          <ReactionChip
                            key={reaction.key}
                            reaction={reaction}
                            count={comment.counts[reaction.key] ?? 0}
                            active={comment.mine.includes(reaction.key)}
                            onToggle={() => toggle(comment.id, reaction.key)}
                          />
                        ))}
                        <ReactionPicker
                          commentId={comment.id}
                          mine={comment.mine}
                          onPick={(key) => toggle(comment.id, key)}
                        />
                      </div>
                    </div>
                  </div>
                </motion.li>
              );
            })}
          </AnimatePresence>
        </ul>

        <form
          onSubmit={submit}
          className="mt-6 rounded-2xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
        >
          <label
            htmlFor={draftId}
            className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"
          >
            Add to the thread
          </label>
          <textarea
            id={draftId}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            rows={3}
            maxLength={600}
            placeholder="What did this miss? Concrete numbers beat opinions."
            className="mt-2 w-full resize-y rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-800 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
          />
          <div className="mt-3 flex items-center justify-between gap-3">
            <span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
              {draft.length}/600
            </span>
            <button
              type="submit"
              className="inline-flex h-9 items-center rounded-full bg-slate-900 px-4 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
              disabled={draft.trim().length === 0}
            >
              Post comment
            </button>
          </div>
        </form>

        <p aria-live="polite" className="sr-only">
          {status}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →