A Slack-style message thread with a root message, replies, toggleable emoji reactions with a quick picker, and a send-on-Enter composer.
npx shadcn@latest add @paragon/thread-panelAlso installs: tooltip
"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>
);
}