Web InnoventixFreeCode

Media Live Badge

Original · free

live stream card with viewer count

byWeb InnoventixReact + Tailwind
medialivebadge
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/media-live-badge.json
media-live-badge.tsx
"use client";

import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type StreamQuality = "auto" | "1080p" | "720p" | "480p";

type ChatLine = {
  id: number;
  user: string;
  color: string;
  text: string;
};

const QUALITIES: readonly StreamQuality[] = ["auto", "1080p", "720p", "480p"];

const CHAT_SEED: readonly Omit<ChatLine, "id">[] = [
  { user: "nadia_builds", color: "text-violet-500 dark:text-violet-400", text: "the flexbox gap fix at 12:04 saved my layout, thank you" },
  { user: "koen.dev", color: "text-emerald-600 dark:text-emerald-400", text: "wait can you show the container query again" },
  { user: "prakash_r", color: "text-sky-600 dark:text-sky-400", text: "second monitor is cropped for anyone else?" },
  { user: "mel_ui", color: "text-rose-500 dark:text-rose-400", text: "subgrid support is finally everywhere, wild" },
  { user: "t_okafor", color: "text-amber-600 dark:text-amber-400", text: "what font is that in the editor" },
  { user: "irene.css", color: "text-indigo-500 dark:text-indigo-400", text: "clamp() on the heading is doing so much work here" },
  { user: "dev_hoshi", color: "text-emerald-600 dark:text-emerald-400", text: "please never stop the friday streams" },
  { user: "amara_j", color: "text-violet-500 dark:text-violet-400", text: "does this work with :has() nested that deep" },
];

function formatViewers(n: number): string {
  if (n >= 1000) {
    const k = n / 1000;
    return `${k >= 100 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, "")}K`;
  }
  return String(n);
}

function formatClock(totalSeconds: number): string {
  const h = Math.floor(totalSeconds / 3600);
  const m = Math.floor((totalSeconds % 3600) / 60);
  const s = totalSeconds % 60;
  const pad = (v: number) => String(v).padStart(2, "0");
  return `${pad(h)}:${pad(m)}:${pad(s)}`;
}

export default function MediaLiveBadge() {
  const reduced = useReducedMotion();
  const panelId = useId();
  const qualityMenuId = useId();

  const [viewers, setViewers] = useState<number>(8342);
  const [elapsed, setElapsed] = useState<number>(4_871);
  const [isLive, setIsLive] = useState<boolean>(true);
  const [muted, setMuted] = useState<boolean>(true);
  const [following, setFollowing] = useState<boolean>(false);
  const [quality, setQuality] = useState<StreamQuality>("auto");
  const [qualityOpen, setQualityOpen] = useState<boolean>(false);
  const [chat, setChat] = useState<ChatLine[]>(() =>
    CHAT_SEED.slice(0, 3).map((line, i) => ({ ...line, id: i })),
  );

  const chatCursor = useRef<number>(3);
  const chatKey = useRef<number>(3);
  const logRef = useRef<HTMLUListElement | null>(null);
  const qualityBtnRef = useRef<HTMLButtonElement | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);

  // Viewer count drift + stream clock
  useEffect(() => {
    if (!isLive) return;
    const id = window.setInterval(() => {
      setViewers((v) => {
        const drift = Math.round((Math.random() - 0.42) * 34);
        return Math.max(1, v + drift);
      });
      setElapsed((t) => t + 3);
    }, 3000);
    return () => window.clearInterval(id);
  }, [isLive]);

  // Chat trickle
  useEffect(() => {
    if (!isLive || reduced) return;
    const id = window.setInterval(() => {
      const next = CHAT_SEED[chatCursor.current % CHAT_SEED.length];
      chatCursor.current += 1;
      chatKey.current += 1;
      setChat((prev) => [...prev.slice(-4), { ...next, id: chatKey.current }]);
    }, 4200);
    return () => window.clearInterval(id);
  }, [isLive, reduced]);

  useEffect(() => {
    const el = logRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [chat]);

  // Quality menu: outside click + Escape
  useEffect(() => {
    if (!qualityOpen) return;
    const onPointer = (e: MouseEvent) => {
      const t = e.target as Node;
      if (menuRef.current?.contains(t) || qualityBtnRef.current?.contains(t)) return;
      setQualityOpen(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setQualityOpen(false);
        qualityBtnRef.current?.focus();
      }
    };
    document.addEventListener("mousedown", onPointer);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onPointer);
      document.removeEventListener("keydown", onKey);
    };
  }, [qualityOpen]);

  const onMenuKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
    const items = Array.from(
      menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [],
    );
    if (items.length === 0) return;
    const idx = items.indexOf(document.activeElement as HTMLButtonElement);
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      const dir = e.key === "ArrowDown" ? 1 : -1;
      const nextIdx = (idx + dir + items.length) % items.length;
      items[nextIdx]?.focus();
    } else if (e.key === "Home") {
      e.preventDefault();
      items[0]?.focus();
    } else if (e.key === "End") {
      e.preventDefault();
      items[items.length - 1]?.focus();
    }
  }, []);

  const openMenu = useCallback(() => {
    setQualityOpen(true);
    window.setTimeout(() => {
      menuRef.current
        ?.querySelector<HTMLButtonElement>('[role="menuitemradio"][aria-checked="true"]')
        ?.focus();
    }, 0);
  }, []);

  const waveBars = useMemo(
    () => [0.42, 0.78, 0.31, 0.94, 0.58, 0.7, 0.36, 0.86, 0.5, 0.66, 0.28, 0.8],
    [],
  );

  const viewerLabel = `${viewers.toLocaleString("en-US")} viewers watching now`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes mlb-pulse-ring {
          0%   { transform: scale(1);   opacity: 0.55; }
          70%  { transform: scale(2.6); opacity: 0; }
          100% { transform: scale(2.6); opacity: 0; }
        }
        @keyframes mlb-dot-breathe {
          0%, 100% { opacity: 1; }
          50%      { opacity: 0.45; }
        }
        @keyframes mlb-scan {
          0%   { transform: translateY(-100%); }
          100% { transform: translateY(900%); }
        }
        @keyframes mlb-eq {
          0%, 100% { transform: scaleY(0.35); }
          50%      { transform: scaleY(1); }
        }
        .mlb-ring   { animation: mlb-pulse-ring 2.1s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
        .mlb-dot    { animation: mlb-dot-breathe 2.1s ease-in-out infinite; }
        .mlb-scan   { animation: mlb-scan 6.5s linear infinite; }
        .mlb-eq-bar { animation: mlb-eq 1.1s ease-in-out infinite; transform-origin: center bottom; }
        @media (prefers-reduced-motion: reduce) {
          .mlb-ring, .mlb-dot, .mlb-scan, .mlb-eq-bar {
            animation: none !important;
          }
          .mlb-ring { opacity: 0; }
          .mlb-eq-bar { transform: scaleY(0.6); }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <header className="mb-8">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Now streaming
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Friday Layout Lab
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Weekly two-hour teardown of real production CSS. This week: rebuilding a
            dashboard grid with subgrid and container queries.
          </p>
        </header>

        <article className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
          {/* Stage */}
          <div className="relative aspect-video w-full overflow-hidden bg-slate-900 dark:bg-slate-950">
            <div
              aria-hidden="true"
              className="absolute inset-0 bg-[radial-gradient(120%_90%_at_20%_10%,rgba(99,102,241,0.55),transparent_60%),radial-gradient(100%_80%_at_85%_85%,rgba(16,185,129,0.35),transparent_55%)]"
            />
            <div
              aria-hidden="true"
              className="absolute inset-0 opacity-[0.18] [background-image:linear-gradient(to_right,rgba(255,255,255,0.5)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.5)_1px,transparent_1px)] [background-size:32px_32px]"
            />
            <div
              aria-hidden="true"
              className="mlb-scan absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-transparent via-white/10 to-transparent"
            />

            {/* Live badge */}
            <div className="absolute left-4 top-4 flex items-center gap-2">
              <span
                className={[
                  "inline-flex items-center gap-2 rounded-md px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-white backdrop-blur",
                  isLive ? "bg-rose-600" : "bg-slate-700/90",
                ].join(" ")}
              >
                <span className="relative flex h-2 w-2">
                  {isLive ? (
                    <>
                      <span className="mlb-ring absolute inline-flex h-2 w-2 rounded-full bg-white" />
                      <span className="mlb-dot relative inline-flex h-2 w-2 rounded-full bg-white" />
                    </>
                  ) : (
                    <span className="relative inline-flex h-2 w-2 rounded-full bg-slate-300" />
                  )}
                </span>
                {isLive ? "Live" : "Offline"}
              </span>

              <span
                className="inline-flex items-center gap-1.5 rounded-md bg-slate-900/70 px-2 py-1 text-[11px] font-medium tabular-nums text-slate-100 backdrop-blur"
                aria-label={viewerLabel}
              >
                <svg
                  viewBox="0 0 16 16"
                  aria-hidden="true"
                  className="h-3.5 w-3.5"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z" />
                  <circle cx="8" cy="8" r="2" />
                </svg>
                <motion.span
                  key={reduced ? "static" : formatViewers(viewers)}
                  initial={reduced ? false : { opacity: 0, y: -4 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.24, ease: "easeOut" }}
                >
                  {formatViewers(viewers)}
                </motion.span>
                <span className="sr-only">viewers</span>
              </span>
            </div>

            {/* Stream clock */}
            <span className="absolute right-4 top-4 rounded-md bg-slate-900/70 px-2 py-1 text-[11px] font-medium tabular-nums text-slate-100 backdrop-blur">
              <span className="sr-only">Streaming for </span>
              {formatClock(elapsed)}
            </span>

            {/* Equalizer */}
            <div className="absolute bottom-4 left-4 flex h-8 items-end gap-[3px]" aria-hidden="true">
              {waveBars.map((h, i) => (
                <span
                  key={i}
                  className={muted || !isLive ? "w-[3px] rounded-full bg-white/25" : "mlb-eq-bar w-[3px] rounded-full bg-white/70"}
                  style={{
                    height: `${Math.round(h * 32)}px`,
                    animationDelay: `${(i * 90) % 1100}ms`,
                  }}
                />
              ))}
            </div>

            {/* Mute toggle */}
            <button
              type="button"
              onClick={() => setMuted((m) => !m)}
              aria-pressed={muted}
              className="absolute bottom-4 right-4 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900/70 text-slate-100 backdrop-blur transition hover:bg-slate-900/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
            >
              <span className="sr-only">{muted ? "Unmute stream" : "Mute stream"}</span>
              {muted ? (
                <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4.5 w-4.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M10 4 6 7.5H3v5h3L10 16V4Z" />
                  <path d="m14 8.5 3.5 3.5M17.5 8.5 14 12" />
                </svg>
              ) : (
                <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4.5 w-4.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M10 4 6 7.5H3v5h3L10 16V4Z" />
                  <path d="M13 7.5a3.5 3.5 0 0 1 0 5M15.5 5.5a6.5 6.5 0 0 1 0 9" />
                </svg>
              )}
            </button>
          </div>

          {/* Meta */}
          <div className="flex flex-wrap items-start gap-4 border-b border-slate-200 p-5 dark:border-slate-800">
            <span
              aria-hidden="true"
              className="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-semibold text-white ring-2 ring-white dark:ring-slate-900"
            >
              LR
            </span>
            <div className="min-w-0 flex-1">
              <h3 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                Lena Ruiz · Layout Lab
              </h3>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                CSS architecture · English · 24.6K followers
              </p>
              <div className="mt-2.5 flex flex-wrap gap-1.5">
                {["subgrid", "container queries", "live coding"].map((tag) => (
                  <span
                    key={tag}
                    className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
                  >
                    {tag}
                  </span>
                ))}
              </div>
            </div>

            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setFollowing((f) => !f)}
                aria-pressed={following}
                className={[
                  "inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900",
                  following
                    ? "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
                    : "bg-indigo-600 text-white hover:bg-indigo-500",
                ].join(" ")}
              >
                <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5" fill={following ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
                  <path d="M8 13.5S2 10.2 2 6.2A3.2 3.2 0 0 1 8 4.6a3.2 3.2 0 0 1 6 1.6c0 4-6 7.3-6 7.3Z" />
                </svg>
                {following ? "Following" : "Follow"}
              </button>

              <div className="relative">
                <button
                  type="button"
                  ref={qualityBtnRef}
                  onClick={() => (qualityOpen ? setQualityOpen(false) : openMenu())}
                  aria-haspopup="menu"
                  aria-expanded={qualityOpen}
                  aria-controls={qualityOpen ? qualityMenuId : undefined}
                  className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-2 text-xs font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  <span className="sr-only">Stream quality: </span>
                  {quality}
                  <svg viewBox="0 0 12 12" aria-hidden="true" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                    <path d="m3 4.5 3 3 3-3" />
                  </svg>
                </button>

                {qualityOpen && (
                  <div
                    ref={menuRef}
                    id={qualityMenuId}
                    role="menu"
                    aria-label="Stream quality"
                    onKeyDown={onMenuKeyDown}
                    className="absolute right-0 z-20 mt-1.5 w-32 overflow-hidden rounded-lg border border-slate-200 bg-white py-1 shadow-lg shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/40"
                  >
                    {QUALITIES.map((q) => (
                      <button
                        key={q}
                        type="button"
                        role="menuitemradio"
                        aria-checked={quality === q}
                        onClick={() => {
                          setQuality(q);
                          setQualityOpen(false);
                          qualityBtnRef.current?.focus();
                        }}
                        className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-slate-700 transition hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:bg-slate-700"
                      >
                        {q}
                        {quality === q && (
                          <svg viewBox="0 0 12 12" aria-hidden="true" className="h-3 w-3 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                            <path d="m2.5 6.5 2.5 2.5 4.5-5" />
                          </svg>
                        )}
                      </button>
                    ))}
                  </div>
                )}
              </div>
            </div>
          </div>

          {/* Chat */}
          <div id={panelId} className="p-5">
            <div className="mb-3 flex items-center justify-between">
              <h4 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                Stream chat
              </h4>
              <button
                type="button"
                onClick={() => setIsLive((v) => !v)}
                aria-pressed={!isLive}
                className="rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
              >
                {isLive ? "Pause updates" : "Resume updates"}
              </button>
            </div>

            <ul
              ref={logRef}
              role="log"
              aria-live="polite"
              aria-relevant="additions"
              aria-label="Live chat messages"
              className="h-32 space-y-2 overflow-y-auto pr-1 text-xs leading-relaxed"
            >
              {chat.map((line) => (
                <motion.li
                  key={line.id}
                  initial={reduced ? false : { opacity: 0, x: -6 }}
                  animate={{ opacity: 1, x: 0 }}
                  transition={{ duration: 0.22, ease: "easeOut" }}
                  className="text-slate-600 dark:text-slate-300"
                >
                  <span className={`font-semibold ${line.color}`}>{line.user}</span>
                  <span className="text-slate-400 dark:text-slate-600">: </span>
                  {line.text}
                </motion.li>
              ))}
            </ul>
          </div>
        </article>
      </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 →