A live-stream chat panel with colored usernames, role badges, an auto-scrolling feed, and a rate-limited composer.
npx shadcn@latest add @paragon/livestream-chat"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Send } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ChatMessage {
id: number;
user: string;
color: string;
text: string;
/** Role badge, e.g. "MOD", "SUB". */
badge?: string;
}
export interface LivestreamChatProps extends React.ComponentProps<"div"> {
title?: string;
/** Seed messages shown before the simulated feed starts. */
seed?: ChatMessage[];
/** Rate-limit cooldown in seconds between sends. */
cooldown?: number;
}
const SCRIPT: Omit<ChatMessage, "id">[] = [
{ user: "pixel_wizard", color: "#8b5cf6", text: "that transition is clean 🔥" },
{ user: "mod_ellie", color: "#10b981", badge: "MOD", text: "welcome in, everyone" },
{ user: "dana_codes", color: "#f43f5e", text: "how'd you build the beam effect?" },
{ user: "sub_marco", color: "#0ea5e9", badge: "SUB", text: "GG 🎉" },
{ user: "frontend_fox", color: "#f97316", text: "shipping this to prod tomorrow" },
{ user: "quiet_owl", color: "#a855f7", text: "reduced motion looks great too" },
{ user: "mod_ellie", color: "#10b981", badge: "MOD", text: "keep it kind in chat 💜" },
{ user: "byte_bard", color: "#22c55e", text: "the tabular nums though 👀" },
];
/**
* A live-stream chat panel: an auto-scrolling message list with colored
* usernames and role badges, plus a composer gated by a rate-limit cooldown.
* New messages arrive on a timer and enter with the house language (fade +
* rise + blur), flattened under reduced motion. The scroll pins to the bottom
* only when the viewer is already there, so reading back is not interrupted.
*/
export function LivestreamChat({
title = "Live chat",
seed = SCRIPT.slice(0, 4).map((m, i) => ({ ...m, id: i })),
cooldown = 3,
className,
...props
}: LivestreamChatProps) {
const [messages, setMessages] = React.useState<ChatMessage[]>(seed);
const [draft, setDraft] = React.useState("");
const [cool, setCool] = React.useState(0);
const reduced = useReducedMotion();
const scrollRef = React.useRef<HTMLDivElement>(null);
const nextId = React.useRef(seed.length);
const scriptIdx = React.useRef(0);
// Simulated incoming feed.
React.useEffect(() => {
const id = window.setInterval(() => {
if (document.hidden) return;
const line = SCRIPT[scriptIdx.current % SCRIPT.length];
scriptIdx.current += 1;
setMessages((prev) => {
const next = [...prev, { ...line, id: nextId.current++ }];
return next.slice(-40);
});
}, 2600);
return () => window.clearInterval(id);
}, []);
// Cooldown ticker.
React.useEffect(() => {
if (cool <= 0) return;
const id = window.setInterval(() => setCool((c) => Math.max(0, c - 1)), 1000);
return () => window.clearInterval(id);
}, [cool]);
// Pin to bottom when already at bottom.
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 48;
if (atBottom) el.scrollTop = el.scrollHeight;
}, [messages]);
function send(e: React.FormEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || cool > 0) return;
setMessages((prev) =>
[
...prev,
{ id: nextId.current++, user: "you", color: "#eab308", text },
].slice(-40),
);
setDraft("");
setCool(cooldown);
requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}
return (
<div
data-slot="livestream-chat"
className={cn(
"flex h-[26rem] w-full max-w-xs 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-3 py-2.5">
<h3 className="text-sm font-semibold">{title}</h3>
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden className="size-1.5 rounded-full bg-destructive" />
Live
</span>
</div>
<div
ref={scrollRef}
className="flex-1 space-y-1.5 overflow-y-auto overscroll-contain px-3 py-2"
>
<AnimatePresence initial={false}>
{messages.map((m) => (
<motion.p
key={m.id}
layout={reduced ? false : "position"}
initial={
reduced
? false
: { opacity: 0, y: 8, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="text-sm leading-snug break-words"
>
{m.badge && (
<span className="mr-1 rounded bg-muted px-1 py-0.5 align-middle text-[9px] font-bold tracking-wide text-muted-foreground">
{m.badge}
</span>
)}
<span className="font-semibold" style={{ color: m.color }}>
{m.user}
</span>
<span className="text-muted-foreground">: </span>
<span className="text-foreground/90">{m.text}</span>
</motion.p>
))}
</AnimatePresence>
</div>
<form
onSubmit={send}
className="flex items-center gap-2 border-t border-border p-2"
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={cool > 0 ? `Slow mode: ${cool}s` : "Send a message"}
aria-label="Chat message"
className="h-8 min-w-0 flex-1 rounded-md bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="submit"
aria-label="Send message"
disabled={cool > 0 || draft.trim().length === 0}
className="pressable grid size-8 shrink-0 place-items-center rounded-md bg-foreground text-background transition-opacity duration-150 disabled:pointer-events-none disabled:opacity-40"
>
<Send className="size-4" />
</button>
</form>
</div>
);
}