SaaS & Ops

Thread Panel

A Slack-style message thread with a root message, replies, toggleable emoji reactions with a quick picker, and a send-on-Enter composer.

Install

npx shadcn@latest add @paragon/thread-panel

Also installs: tooltip

thread-panel.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SmilePlus, Send } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

/** One rolling reaction-count digit group — old value slides up, new rises in. */
function CountRoll({ value, reduced }: { value: number; reduced: boolean }) {
  return (
    <span className="relative inline-flex h-4 items-center overflow-hidden tabular-nums">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={value}
          initial={reduced ? { opacity: 0 } : { y: "70%", opacity: 0 }}
          animate={reduced ? { opacity: 1 } : { y: 0, opacity: 1 }}
          exit={reduced ? { opacity: 0 } : { y: "-70%", opacity: 0 }}
          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          className="inline-block"
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export interface ThreadReaction {
  emoji: string;
  count: number;
  /** Whether the current user has reacted. */
  reacted?: boolean;
}

export interface ThreadMessage {
  id: string;
  author: string;
  /** Preformatted timestamp label. */
  time: string;
  body: string;
  reactions?: ThreadReaction[];
}

export interface ThreadPanelProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  /** Root message that started the thread. */
  root?: ThreadMessage;
  replies?: ThreadMessage[];
  /** Emoji offered by the quick reaction picker. */
  quickReactions?: string[];
  onSend?: (body: string) => void;
}

function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "•";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function hueOf(value: string): number {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (hash << 5) - hash + value.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash) % 360;
}

const DEFAULT_ROOT: ThreadMessage = {
  id: "root",
  author: "Priya Raghavan",
  time: "9:41 AM",
  body: "Heads up — the billing migration is behind a flag on staging. Please smoke-test invoice generation before we flip prod.",
  reactions: [{ emoji: "👀", count: 3, reacted: true }],
};

const DEFAULT_REPLIES: ThreadMessage[] = [
  {
    id: "r1",
    author: "Marcus Kane",
    time: "9:44 AM",
    body: "Ran a batch of 50 — all reconciled. One edge case on partial refunds, filing ENG-484.",
    reactions: [{ emoji: "🙏", count: 2 }],
  },
  {
    id: "r2",
    author: "Dana Ortiz",
    time: "9:52 AM",
    body: "Nice. I'll add a retry-queue dashboard so we can watch the flip live.",
    reactions: [
      { emoji: "🔥", count: 4 },
      { emoji: "✅", count: 1, reacted: true },
    ],
  },
];

const DEFAULT_QUICK = ["👍", "🎉", "🙏", "👀", "🔥", "✅"];

/**
 * A Slack-style thread panel: a root message, its replies, and a composer.
 * Each message shows an avatar, author, time and a reaction bar. Reactions
 * toggle the current user's vote (count animates via a per-digit-safe pop),
 * and a "+" opens a small emoji picker. New replies enter with the house
 * language (fade + rise + blur), collapsing to a fade under reduced motion.
 * The composer sends on Enter (Shift+Enter for a newline).
 */
export function ThreadPanel({
  root = DEFAULT_ROOT,
  replies = DEFAULT_REPLIES,
  quickReactions = DEFAULT_QUICK,
  onSend,
  className,
  ...props
}: ThreadPanelProps) {
  const reduced = useReducedMotion();
  const [rootState, setRootState] = React.useState(root);
  const [messages, setMessages] = React.useState(replies);
  const [draft, setDraft] = React.useState("");
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const nextId = React.useRef(1);

  const toggleReaction = (
    messageId: string,
    emoji: string,
    isRoot: boolean,
  ) => {
    const apply = (message: ThreadMessage): ThreadMessage => {
      if (message.id !== messageId) return message;
      const reactions = [...(message.reactions ?? [])];
      const index = reactions.findIndex((r) => r.emoji === emoji);
      if (index === -1) {
        reactions.push({ emoji, count: 1, reacted: true });
      } else {
        const r = reactions[index];
        const reacted = !r.reacted;
        const count = r.count + (reacted ? 1 : -1);
        if (count <= 0) reactions.splice(index, 1);
        else reactions[index] = { ...r, reacted, count };
      }
      return { ...message, reactions };
    };
    if (isRoot) setRootState(apply);
    else setMessages((list) => list.map(apply));
  };

  const send = () => {
    const body = draft.trim();
    if (!body) return;
    const message: ThreadMessage = {
      id: `local-${nextId.current++}`,
      author: "You",
      time: "now",
      body,
    };
    setMessages((list) => [...list, message]);
    onSend?.(body);
    setDraft("");
    requestAnimationFrame(() => {
      scrollRef.current?.scrollTo({
        top: scrollRef.current.scrollHeight,
        behavior: reduced ? "auto" : "smooth",
      });
    });
  };

  return (
    <TooltipProvider>
    <div
      data-slot="thread-panel"
      className={cn(
        "flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
        <span className="text-sm font-medium">Thread</span>
        <span className="text-xs text-muted-foreground tabular-nums">
          {messages.length} {messages.length === 1 ? "reply" : "replies"}
        </span>
      </div>

      <div
        ref={scrollRef}
        className="max-h-96 space-y-4 overflow-y-auto p-4 [scrollbar-width:thin]"
      >
        <Message
          message={rootState}
          quickReactions={quickReactions}
          onToggle={(emoji) => toggleReaction(rootState.id, emoji, true)}
          reduced={!!reduced}
          animate={false}
        />
        <div className="border-t border-border pt-3">
          <p className="mb-3 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
            {messages.length} replies
          </p>
          <div className="space-y-4">
            <AnimatePresence initial={false}>
              {messages.map((message) => (
                <Message
                  key={message.id}
                  message={message}
                  quickReactions={quickReactions}
                  onToggle={(emoji) => toggleReaction(message.id, emoji, false)}
                  reduced={!!reduced}
                  animate
                />
              ))}
            </AnimatePresence>
          </div>
        </div>
      </div>

      <div className="border-t border-border p-3">
        <div className="flex items-end gap-2 rounded-lg bg-background px-3 py-2 shadow-border focus-within:ring-2 focus-within:ring-ring">
          <textarea
            value={draft}
            rows={1}
            placeholder="Reply…"
            aria-label="Reply to thread"
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter" && !event.shiftKey) {
                event.preventDefault();
                send();
              }
            }}
            className="max-h-24 min-h-6 flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
          />
          <Tooltip>
            <TooltipTrigger asChild>
              <button
                type="button"
                onClick={send}
                disabled={!draft.trim()}
                aria-label="Send reply"
                className={cn(
                  "pressable flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground",
                  "transition-[scale,opacity,background-color] duration-(--duration-fast) hover:bg-primary/90",
                  "disabled:pointer-events-none disabled:opacity-40",
                  "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
                )}
              >
                <Send className="size-3.5" aria-hidden />
              </button>
            </TooltipTrigger>
            <TooltipContent>
              Send <span className="text-primary-foreground/60">↵</span>
            </TooltipContent>
          </Tooltip>
        </div>
      </div>
    </div>
    </TooltipProvider>
  );
}

function Message({
  message,
  quickReactions,
  onToggle,
  reduced,
  animate,
}: {
  message: ThreadMessage;
  quickReactions: string[];
  onToggle: (emoji: string) => void;
  reduced: boolean;
  animate: boolean;
}) {
  const [pickerOpen, setPickerOpen] = React.useState(false);

  const content = (
    <div className="group/msg flex gap-2.5">
      <span
        className="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-medium"
        style={{
          backgroundColor: `oklch(0.9 0.05 ${hueOf(message.author)})`,
          color: `oklch(0.4 0.09 ${hueOf(message.author)})`,
        }}
      >
        {initials(message.author)}
      </span>
      <div className="min-w-0 flex-1">
        <div className="flex items-baseline gap-2">
          <span className="text-sm font-medium">{message.author}</span>
          <span className="text-[11px] text-muted-foreground tabular-nums">
            {message.time}
          </span>
        </div>
        <p className="mt-0.5 text-sm leading-relaxed text-foreground/90">
          {message.body}
        </p>

        <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
          <AnimatePresence initial={false} mode="popLayout">
            {message.reactions?.map((reaction) => (
              <motion.button
                key={reaction.emoji}
                type="button"
                layout={!reduced}
                onClick={() => onToggle(reaction.emoji)}
                aria-pressed={reaction.reacted}
                aria-label={`${reaction.emoji} reaction, ${reaction.count}`}
                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
                animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1 }}
                exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
                whileTap={reduced ? undefined : { scale: 0.94 }}
                transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                className={cn(
                  "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs outline-none",
                  "transition-colors duration-(--duration-fast)",
                  "focus-visible:ring-2 focus-visible:ring-ring",
                  reaction.reacted
                    ? "border-primary/40 bg-primary/10 text-foreground"
                    : "border-border bg-secondary/50 text-muted-foreground hover:text-foreground",
                )}
              >
                <span aria-hidden className="leading-none">
                  {reaction.emoji}
                </span>
                <CountRoll value={reaction.count} reduced={reduced} />
              </motion.button>
            ))}
          </AnimatePresence>

          <div className="relative">
            <Tooltip>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  aria-label="Add reaction"
                  aria-expanded={pickerOpen}
                  onClick={() => setPickerOpen((v) => !v)}
                  onBlur={() => setPickerOpen(false)}
                  className={cn(
                    "pressable inline-flex size-6 items-center justify-center rounded-full border border-border text-muted-foreground outline-none",
                    "opacity-0 transition-[opacity,color] duration-(--duration-fast) group-hover/msg:opacity-100 focus-visible:opacity-100",
                    "hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
                    "motion-reduce:opacity-100",
                  )}
                >
                  <SmilePlus className="size-3.5" aria-hidden />
                </button>
              </TooltipTrigger>
              <TooltipContent>Add reaction</TooltipContent>
            </Tooltip>
            <AnimatePresence>
              {pickerOpen && (
                <motion.div
                  role="menu"
                  aria-label="Pick a reaction"
                  onKeyDown={(event) => {
                    if (event.key === "Escape") {
                      event.preventDefault();
                      setPickerOpen(false);
                    }
                  }}
                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96, filter: "blur(4px)" }}
                  animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                  exit={reduced ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.98, filter: "blur(4px)" }}
                  transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
                  style={{ transformOrigin: "bottom left" }}
                  className="absolute bottom-full left-0 z-10 mb-1.5 flex gap-0.5 rounded-lg bg-popover p-1 shadow-overlay"
                >
                  {quickReactions.map((emoji) => (
                    <button
                      key={emoji}
                      type="button"
                      role="menuitem"
                      onMouseDown={(event) => {
                        event.preventDefault();
                        onToggle(emoji);
                        setPickerOpen(false);
                      }}
                      className={cn(
                        "flex size-7 items-center justify-center rounded-md text-base outline-none",
                        "transition-[scale,background-color] duration-(--duration-fast) ease-out",
                        "hover:bg-accent active:scale-[0.9] focus-visible:ring-2 focus-visible:ring-ring",
                      )}
                      aria-label={`React ${emoji}`}
                    >
                      {emoji}
                    </button>
                  ))}
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </div>
  );

  if (!animate) return content;

  return (
    <motion.div
      layout={!reduced}
      initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
      animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
      transition={{ type: "spring", duration: 0.35, bounce: 0 }}
    >
      {content}
    </motion.div>
  );
}