Media Live Badge
Original · freelive stream card with viewer count
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"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
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 quoteSimilar components
Browse all →
Media Video Player
Originalcustom video player controls UI

Media Audio Player
Originalaudio player with progress and volume

Media Podcast
Originalpodcast episode player card

Media Playlist
Originalmusic playlist with now-playing

Media Mini Player
Originalcompact sticky mini player

Media Video Card
Originalvideo thumbnail card with play overlay

Media Waveform
Originalaudio waveform scrubber

Media Video Hero
Originalhero with a video-style backdrop

Media Gallery Player
Originalmedia gallery with a lightbox player

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

