A newspaper-style ticker for breaking headlines. A black bar scrolls timestamped, tagged headlines past a red Live label whose dot beats and sends out a ring. Underneath, a slower strip of market prices runs the other way with green and red movement arrows. Both pause on hover and from the button at the end of the bar. The masthead and lead story around it show the bar in a real page, and the whole thing fits a 360px phone.
import { useEffect, useRef, useState } from "react";
// The headlines that scroll through the black bar.
const headlines = [
{ time: "14:02", tag: "Transit", text: "Harbour tunnel reopens two weeks ahead of schedule" },
{ time: "13:47", tag: "Council", text: "Budget vote delayed after late amendment on library hours" },
{ time: "13:31", tag: "Weather", text: "Coastal wind warning lifted, ferries resume from 3pm" },
{ time: "13:05", tag: "Business", text: "Calloway Mills to add 220 jobs at its riverside plant" },
{ time: "12:48", tag: "Sport", text: "Marlowe United name unchanged side for Saturday's final" },
{ time: "12:20", tag: "Schools", text: "Free breakfast scheme extended to every primary in the district" },
];
// The slower strip of market prices underneath.
const markets = [
{ name: "HBX 100", value: "7,412.60", move: "+0.84%", up: true },
{ name: "MERIDIAN 50", value: "3,208.15", move: "-0.31%", up: false },
{ name: "CALLOWAY", value: "148.20", move: "+2.10%", up: true },
{ name: "TIDE ENERGY", value: "62.75", move: "-1.42%", up: false },
{ name: "GOLD", value: "2,391.00", move: "+0.22%", up: true },
{ name: "NORTH RAIL", value: "19.84", move: "+0.65%", up: true },
{ name: "BRENNAN BANK", value: "311.40", move: "-0.08%", up: false },
];
const updates = [
{ time: "2 min ago", text: "First cars are through. Traffic is light in both directions." },
{ time: "9 min ago", text: "Engineers sign off the final ventilation test." },
{ time: "26 min ago", text: "Mayor to cut the ribbon at the north portal." },
];
const css = `
@keyframes live-news-ticker-bar-with-pulsing-dot-slide { to { transform: translate3d(-50%, 0, 0); } }
@keyframes live-news-ticker-bar-with-pulsing-dot-ping { 0% { transform: scale(1); opacity: 0.75; } 80%, 100% { transform: scale(3.2); opacity: 0; } }
@keyframes live-news-ticker-bar-with-pulsing-dot-beat { 0%, 100% { transform: scale(1); } 12% { transform: scale(1.35); } 24% { transform: scale(1); } }
@keyframes live-news-ticker-bar-with-pulsing-dot-in { from { opacity: 0; transform: translate3d(0, 14px, 0); } to { opacity: 1; transform: none; } }
@keyframes live-news-ticker-bar-with-pulsing-dot-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
.live-news-ticker-bar-with-pulsing-dot-track { animation: live-news-ticker-bar-with-pulsing-dot-slide linear infinite; will-change: transform; }
.live-news-ticker-bar-with-pulsing-dot-lane:hover .live-news-ticker-bar-with-pulsing-dot-track,
.live-news-ticker-bar-with-pulsing-dot-lane:focus-within .live-news-ticker-bar-with-pulsing-dot-track { animation-play-state: paused !important; }
.live-news-ticker-bar-with-pulsing-dot-ping { animation: live-news-ticker-bar-with-pulsing-dot-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
.live-news-ticker-bar-with-pulsing-dot-beat { animation: live-news-ticker-bar-with-pulsing-dot-beat 1.6s ease-out infinite; }
.live-news-ticker-bar-with-pulsing-dot-in { animation: live-news-ticker-bar-with-pulsing-dot-in 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
.live-news-ticker-bar-with-pulsing-dot-wipe { animation: live-news-ticker-bar-with-pulsing-dot-wipe 0.9s cubic-bezier(0.7, 0, 0.2, 1) both; }
.live-news-ticker-bar-with-pulsing-dot-fade {
-webkit-mask-image: linear-gradient(to right, transparent, #000 48px, #000 calc(100% - 48px), transparent);
mask-image: linear-gradient(to right, transparent, #000 48px, #000 calc(100% - 48px), transparent);
}
@media (prefers-reduced-motion: reduce) {
.live-news-ticker-bar-with-pulsing-dot-track,
.live-news-ticker-bar-with-pulsing-dot-ping,
.live-news-ticker-bar-with-pulsing-dot-beat,
.live-news-ticker-bar-with-pulsing-dot-in,
.live-news-ticker-bar-with-pulsing-dot-wipe { animation: none; }
.live-news-ticker-bar-with-pulsing-dot-lane { overflow-x: auto; }
}
`;
function LiveDot({ className = "" }: { className?: string }) {
return (
<span aria-hidden="true" className={`relative flex h-2.5 w-2.5 flex-none ${className}`}>
<span className="live-news-ticker-bar-with-pulsing-dot-ping absolute inset-0 rounded-full bg-current" />
<span className="live-news-ticker-bar-with-pulsing-dot-beat relative h-2.5 w-2.5 rounded-full bg-current" />
</span>
);
}
export default function LiveNewsTickerBarWithPulsingDot() {
const section = useRef<HTMLElement>(null);
const [onScreen, setOnScreen] = useState(true);
const [paused, setPaused] = useState(false);
const [age, setAge] = useState(8);
// Stop the tickers when the section is scrolled away.
useEffect(() => {
const el = section.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);
// "Updated N seconds ago" counts up while the section is visible.
useEffect(() => {
if (!onScreen) return;
const id = window.setInterval(() => setAge((a) => (a >= 59 ? 0 : a + 1)), 1000);
return () => window.clearInterval(id);
}, [onScreen]);
const state = onScreen && !paused ? "running" : "paused";
return (
<section ref={section} className="overflow-hidden bg-white text-neutral-950">
<style>{css}</style>
<header className="live-news-ticker-bar-with-pulsing-dot-in mx-auto flex max-w-6xl items-center justify-between gap-4 px-5 py-5 sm:px-8">
<p className="hidden w-40 text-xs uppercase tracking-widest text-neutral-600 sm:block">Thursday 9 October</p>
<a href="#" className="font-serif text-2xl font-bold tracking-tight focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-4 sm:text-4xl">
The Meridian Ledger
</a>
<div className="flex justify-end sm:w-40">
<a href="#" className="inline-flex h-10 items-center bg-neutral-950 px-4 text-xs font-semibold uppercase tracking-widest text-white transition hover:bg-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600 focus-visible:ring-offset-2">
Subscribe
</a>
</div>
</header>
{/* The ticker bar */}
<div className="live-news-ticker-bar-with-pulsing-dot-wipe flex h-14 items-stretch bg-neutral-950 text-white" role="region" aria-label="Live headlines">
<p className="relative z-10 flex flex-none items-center gap-2.5 bg-red-600 pl-5 pr-4 text-sm font-bold uppercase tracking-[0.18em] sm:pl-8">
<LiveDot className="text-white" />
Live
<span aria-hidden="true" className="absolute -right-3 top-0 h-full w-3 bg-red-600 [clip-path:polygon(0_0,100%_50%,0_100%)]" />
</p>
<div className="live-news-ticker-bar-with-pulsing-dot-lane live-news-ticker-bar-with-pulsing-dot-fade min-w-0 flex-1 overflow-hidden">
<div className="live-news-ticker-bar-with-pulsing-dot-track flex h-full w-max" style={{ animationDuration: "48s", animationPlayState: state }}>
{[0, 1].map((copy) => (
<ul key={copy} aria-hidden={copy === 1} className="flex flex-none items-center">
{headlines.map((h) => (
<li key={h.text} className="flex items-center">
<a href="#" tabIndex={copy === 1 ? -1 : 0} className="group flex h-14 items-center gap-3 whitespace-nowrap px-6 text-[15px] focus:outline-none focus-visible:bg-white/10">
<span className="font-mono text-xs text-red-400">{h.time}</span>
<span className="border border-white/25 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-white/80">{h.tag}</span>
<span className="font-medium underline-offset-4 group-hover:underline group-focus-visible:underline">{h.text}</span>
</a>
<span aria-hidden="true" className="h-4 w-px rotate-[20deg] bg-white/30" />
</li>
))}
</ul>
))}
</div>
</div>
<button
type="button"
aria-pressed={paused}
aria-label={paused ? "Play the ticker" : "Pause the ticker"}
onClick={() => setPaused((p) => !p)}
className="flex w-14 flex-none items-center justify-center border-l border-white/15 text-white/80 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500"
>
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="currentColor">
{paused ? <path d="M6 4.5v11l9-5.5Z" /> : <path d="M5.5 4h3v12h-3zM11.5 4h3v12h-3z" />}
</svg>
</button>
</div>
{/* Markets strip, moving the other way */}
<div className="live-news-ticker-bar-with-pulsing-dot-lane overflow-hidden border-b border-neutral-200 bg-neutral-50" role="region" aria-label="Market prices">
<div className="live-news-ticker-bar-with-pulsing-dot-track flex w-max" style={{ animationDuration: "70s", animationDirection: "reverse", animationPlayState: state }}>
{[0, 1].map((copy) => (
<ul key={copy} aria-hidden={copy === 1} className="flex flex-none items-center">
{markets.map((m) => (
<li key={m.name} className="flex h-10 items-center gap-2.5 whitespace-nowrap border-r border-neutral-200 px-6 font-mono text-xs">
<span className="font-semibold text-neutral-950">{m.name}</span>
<span className="text-neutral-600">{m.value}</span>
<span className={`flex items-center gap-1 font-semibold ${m.up ? "text-emerald-700" : "text-red-700"}`}>
<svg viewBox="0 0 10 10" aria-hidden="true" className={`h-2 w-2 ${m.up ? "" : "rotate-180"}`} fill="currentColor"><path d="M5 1 9.5 9h-9Z" /></svg>
{m.move}
</span>
</li>
))}
</ul>
))}
</div>
</div>
{/* Lead story, so the bar sits in a real page */}
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-12 sm:px-8 sm:py-16 lg:grid-cols-12">
<article className="live-news-ticker-bar-with-pulsing-dot-in lg:col-span-8" style={{ animationDelay: "0.25s" }}>
<p className="flex items-center gap-2.5 text-xs font-bold uppercase tracking-[0.18em] text-red-600">
<LiveDot />
Developing story
</p>
<h2 className="mt-4 font-serif text-4xl font-bold leading-[1.05] tracking-tight sm:text-6xl">
Harbour tunnel reopens, and the morning commute gets 25 minutes back
</h2>
<p className="mt-5 max-w-2xl text-lg leading-relaxed text-neutral-700">
After fourteen months of repairs the crossing is carrying traffic again. Bus routes 4, 11 and 60 return to their old timetables from Monday.
</p>
<p className="mt-6 text-sm text-neutral-600">
By Odile Brennan <span aria-hidden="true">/</span> Updated <span className="font-mono tabular-nums text-neutral-950">{age === 0 ? "just now" : `${age}s ago`}</span>
</p>
</article>
<aside className="live-news-ticker-bar-with-pulsing-dot-in border-t-2 border-neutral-950 pt-4 lg:col-span-4" style={{ animationDelay: "0.4s" }}>
<h3 className="text-xs font-bold uppercase tracking-[0.18em]">As it happens</h3>
<ol className="mt-4 space-y-4">
{updates.map((u, i) => (
<li key={u.time} className="relative border-l border-neutral-300 pl-5">
<span aria-hidden="true" className={`absolute -left-[5px] top-1 h-[9px] w-[9px] rounded-full ${i === 0 ? "bg-red-600" : "bg-neutral-300"}`} />
<p className="font-mono text-xs text-neutral-600">{u.time}</p>
<p className="mt-1 text-[15px] leading-snug text-neutral-900">{u.text}</p>
</li>
))}
</ol>
</aside>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/live-news-ticker-bar-with-pulsing-dot.json
The track contains the headline list twice. A linear keyframe moves it left by 50 percent of its width, which is exactly one list, then restarts. Because the second list is identical, the restart is invisible.
It is two circles on top of each other. The back one scales up to about three times its size while fading to nothing, which reads as a ring leaving the dot. The front one gives a short beat at the start of each cycle. Both are CSS keyframes that animate only transform and opacity.
Replace the `headlines` array with data from your own source and render the component with it. If the list changes length, keep the duration proportional to the number of items so the reading speed stays the same.
No. Each track is one element moved with a transform, which the browser handles on the compositor. The component also pauses both tracks when the section is off screen.
No. Only React and Tailwind. The plain HTML copy keeps scrolling too, because the movement is CSS.
Related: Dual Row Logo Marquee with Edge Fade, Three Column Testimonial Wall with Vertical Scroll, Store Navbar with Announcement Bar and Category Row.
More marquees or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.