Live Status Buttons with Pulsing Dot and Light Sweep

A button that says something is happening right now. A coloured dot sits beside the label and sends out two overlapping rings, and every few seconds a soft band of light slides across the button face. On hover or keyboard focus the button lifts, picks up a coloured border and shadow, and both animations speed up. The demo shows a large version on a live stream card and three smaller ones in sky, emerald and rose, one of them dark. On phones the card stacks and the small buttons wrap onto separate lines.

Code

// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/live-button.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import type { CSSProperties, ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Eye, Radio } from "lucide-react";

const session = { title: "Glazing a stoneware bowl, start to finish", host: "Ines Okafor", initials: "IO", watching: "1,284" };

const others = [
  { label: "Join the waiting room", color: "#0ea5e9", dark: false },
  { label: "All systems normal", color: "#10b981", dark: false },
  { label: "Recording in progress", color: "#f43f5e", dark: true },
];

/**
 * A button with a live dot: the dot pings all the time, a band of light
 * crosses the face every few seconds, and both speed up on hover or focus.
 */
function LiveButton({ children, color = "#f43f5e", dark = false, large = false }: { children: ReactNode; color?: string; dark?: boolean; large?: boolean }) {
  return (
    <a
      href="#"
      style={{ "--live": color } as CSSProperties}
      className={`live-status-buttons-with-pulsing-dot-and-light-sweep group relative inline-flex items-center justify-center gap-3.5 overflow-hidden rounded-xl border font-semibold tracking-wide transition-[transform,box-shadow,border-color] duration-300 ease-out hover:-translate-y-0.5 hover:border-[color:var(--live)] hover:shadow-[0_10px_30px_-10px_var(--live)] focus:outline-none focus-visible:border-[color:var(--live)] focus-visible:ring-2 focus-visible:ring-[color:var(--live)] focus-visible:ring-offset-2 active:translate-y-0 active:scale-[0.97] ${
        large ? "h-14 px-7 text-base" : "h-12 px-5 text-sm"
      } ${dark ? "border-white/15 bg-stone-900 text-white focus-visible:ring-offset-stone-900" : "border-stone-300 bg-white text-stone-900"}`}
    >
      <span className="live-sweep pointer-events-none absolute inset-y-0 left-0 w-1/2" aria-hidden="true" />
      <span className="relative flex h-3 w-3 shrink-0" aria-hidden="true">
        <span className="live-ping absolute inset-0 rounded-full bg-[color:var(--live)]" />
        <span className="live-ping live-ping-late absolute inset-0 rounded-full bg-[color:var(--live)]" />
        <span className="relative h-3 w-3 rounded-full bg-[color:var(--live)] shadow-[0_0_10px_var(--live)] transition-transform duration-300 group-hover:scale-125" />
      </span>
      <span className="relative whitespace-nowrap">{children}</span>
    </a>
  );
}

export default function LiveStatusButtonsWithPulsingDotAndLightSweep() {
  const reduce = useReducedMotion();
  const rise = (delay: number) => ({
    initial: reduce ? false : { opacity: 0, y: 18 },
    animate: { opacity: 1, y: 0 },
    transition: { type: "spring" as const, stiffness: 120, damping: 20, delay },
  });

  return (
    <section className="bg-stone-100 px-4 py-16 sm:px-6 sm:py-24">
      <style>{`
        @keyframes live-status-buttons-with-pulsing-dot-and-light-sweep-ping {
          0% { transform: scale(1); opacity: 0.65; }
          70%, 100% { transform: scale(2.4); opacity: 0; }
        }
        @keyframes live-status-buttons-with-pulsing-dot-and-light-sweep-sweep {
          0%, 55% { transform: translateX(-120%) skewX(-18deg); }
          100% { transform: translateX(320%) skewX(-18deg); }
        }
        .live-status-buttons-with-pulsing-dot-and-light-sweep .live-ping {
          animation: live-status-buttons-with-pulsing-dot-and-light-sweep-ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
        }
        .live-status-buttons-with-pulsing-dot-and-light-sweep .live-ping-late { animation-delay: 1s; }
        .live-status-buttons-with-pulsing-dot-and-light-sweep .live-sweep {
          background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--live) 22%, transparent), transparent);
          animation: live-status-buttons-with-pulsing-dot-and-light-sweep-sweep 3.4s ease-in-out infinite;
        }
        .live-status-buttons-with-pulsing-dot-and-light-sweep:hover .live-ping,
        .live-status-buttons-with-pulsing-dot-and-light-sweep:focus-visible .live-ping { animation-duration: 1.1s; }
        .live-status-buttons-with-pulsing-dot-and-light-sweep:hover .live-sweep,
        .live-status-buttons-with-pulsing-dot-and-light-sweep:focus-visible .live-sweep { animation-duration: 1.5s; }
        @media (prefers-reduced-motion: reduce) {
          .live-status-buttons-with-pulsing-dot-and-light-sweep .live-ping,
          .live-status-buttons-with-pulsing-dot-and-light-sweep .live-sweep { animation: none; opacity: 0; }
        }
      `}</style>

      <div className="mx-auto max-w-4xl">
        <motion.div {...rise(0)} className="overflow-hidden rounded-3xl bg-white shadow-xl shadow-stone-900/5 ring-1 ring-stone-900/5 md:grid md:grid-cols-[1.05fr_1fr]">
          {/* A drawn stand-in for the video frame. */}
          <div className="relative min-h-[220px] bg-gradient-to-br from-amber-200 via-orange-300 to-rose-400" aria-hidden="true">
            <div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-stone-900/45 to-transparent" />
            <div className="absolute left-1/2 top-1/2 h-28 w-44 -translate-x-1/2 -translate-y-1/3 rounded-b-[5rem] rounded-t-xl bg-gradient-to-b from-stone-50 to-stone-300 shadow-2xl shadow-stone-900/40" />
            <div className="absolute left-1/2 top-1/2 h-4 w-44 -translate-x-1/2 -translate-y-[2.6rem] rounded-full bg-sky-900/70" />
            <span className="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-md bg-rose-600 px-2 py-1 text-xs font-bold uppercase tracking-wider text-white">
              <Radio className="h-3.5 w-3.5" /> Live
            </span>
            <span className="absolute bottom-4 left-4 inline-flex items-center gap-1.5 rounded-md bg-black/55 px-2 py-1 text-xs font-medium text-white">
              <Eye className="h-3.5 w-3.5" /> {session.watching} watching
            </span>
          </div>

          <div className="p-6 sm:p-9">
            <p className="text-sm font-semibold uppercase tracking-widest text-rose-600">Studio session</p>
            <h2 className="mt-3 text-2xl font-semibold tracking-tight text-stone-900 sm:text-3xl">{session.title}</h2>
            <p className="mt-4 flex items-center gap-3 text-sm text-stone-600">
              <span className="flex h-9 w-9 items-center justify-center rounded-full bg-stone-900 text-xs font-semibold text-white">{session.initials}</span>
              With {session.host}, streaming from the Kiln Room
            </p>
            <div className="mt-8">
              <LiveButton large>Watch live now</LiveButton>
            </div>
          </div>
        </motion.div>

        <motion.div {...rise(0.12)} className="mt-8 flex flex-wrap items-center gap-3 rounded-3xl bg-stone-200/70 p-5 sm:p-6">
          <p className="w-full text-sm font-medium text-stone-600">Same button, other states</p>
          {others.map((o) => (
            <LiveButton key={o.label} color={o.color} dark={o.dark}>
              {o.label}
            </LiveButton>
          ))}
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/live-status-buttons-with-pulsing-dot-and-light-sweep.json

When to use it

How to customize it

Accessibility

Questions

How do I make the dot pulse only while something is live?

Render the two `.live-ping` spans only when your live flag is true, or add a class that sets `animation: none` on them. The solid dot can stay and change to grey for an offline state.

Why two rings instead of one?

A single ring leaves a gap at the end of each cycle where nothing moves. The second ring starts one second later, so there is always one expanding, which reads as a steady signal.

Does color-mix work everywhere?

It is supported in current Chrome, Edge, Firefox and Safari. In an older browser the sweep is simply not drawn. The dot, rings and hover styles do not depend on it.

Can I put an icon in place of the dot?

Yes. Replace the dot wrapper with a lucide icon and keep one ring span behind it if you still want the pulse. Give the icon `aria-hidden` so it is not read out.

Is it fine to have several on one page?

Technically yes, since the animations are transforms and opacity only. In practice a pulsing dot loses its meaning if many things pulse, so use one per view.

Adapted from Live Button in Eldora UI, used under its open-source licence. Licences and credits

Related: Shimmer Button with Light Sweep in Three Sizes, Live News Ticker Bar with Pulsing Dot, Button with Rotating Gradient Glow Border.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

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.

Product

Who It's For

Features

Resources

Company

View Sitemap