Media

Livestream Chat

A live-stream chat panel with colored usernames, role badges, an auto-scrolling feed, and a rate-limited composer.

Install

npx shadcn@latest add @paragon/livestream-chat

livestream-chat.tsx

"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>
  );
}