All patterns

Live Viewer Count

One ring per arrival, digits rolling to the new total, and the size of the jump floating off above it.

socialenergeticfuturisticautomatic · finite · intermediate · ~3.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

338 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Live Viewer Count
 *
 * People arriving at a live stream: the badge sends out one ring per
 * arrival, the count rolls digit by digit, and the size of the jump
 * floats off above it. Pulses are events, not a loop.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The stage is a CSS gradient with a drawn wireframe standing in for
 * video — no asset needed —
 * and surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `startCount`, `arrivals`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LiveViewerCountProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Viewers already watching when the card mounts. */
  startCount?: number;
  /** Scripted arrivals: how many joined, and when, in ms from mount. */
  arrivals?: { atMs: number; joined: number }[];
  title?: string;
  host?: string;
  /** Called with the new total after each arrival. */
  onCountChange?: (total: number) => void;
  /** Live color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Seconds for a digit to roll to its next value. */
  roll: number;
  /** How far the arrival ring travels before it is spent. */
  ring: number;
  /** Seconds the ring takes to expand and fade. */
  ringSeconds: number;
};

// No springs: numerals must not overshoot, so the roll is a tween, and
// the ring is a one-shot expansion rather than a heartbeat. A live badge
// that pulses forever burns attention it will need later, when something
// actually happens. Variants differ in how loud each arrival is.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A small ring, a quick roll. For a card in a sidebar.
  subtle: { roll: 0.24, ring: 2, ringSeconds: 0.55 },
  // Clearly an event, still calm. All-purpose.
  default: { roll: 0.3, ring: 2.6, ringSeconds: 0.7 },
  // A wide ring and a slower roll, for a stream page hero.
  playful: { roll: 0.36, ring: 3.2, ringSeconds: 0.9 },
};

const ACCENT = "#E0484D";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_ARRIVALS = [
  { atMs: 800, joined: 7 },
  { atMs: 2000, joined: 12 },
  { atMs: 3300, joined: 9 },
];

/** Deterministic on the server and on the client — no locale lookup. */
function formatCount(value: number) {
  return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

export default function LiveViewerCount({
  variant = "default",
  startCount = 1284,
  arrivals = DEFAULT_ARRIVALS,
  title = "Design review, live",
  host = "Product Guild",
  onCountChange,
  accent = ACCENT,
}: LiveViewerCountProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [count, setCount] = useState(startCount);
  const [event, setEvent] = useState<{ id: number; joined: number } | null>(null);

  const notify = useRef(onCountChange);
  useEffect(() => {
    notify.current = onCountChange;
  }, [onCountChange]);

  // A primitive dependency: the default array is a new identity every
  // render, and depending on it directly would restart the schedule.
  const schedule = arrivals.map((a) => `${a.atMs}:${a.joined}`).join(",");
  useEffect(() => {
    let total = startCount;
    const timers = schedule.split(",").map((entry, index) => {
      const [atMs, joined] = entry.split(":").map(Number);
      return setTimeout(() => {
        total += joined;
        setCount(total);
        setEvent({ id: index, joined });
        notify.current?.(total);
      }, atMs);
    });
    return () => timers.forEach(clearTimeout);
  }, [schedule, startCount]);

  // The float is spent shortly after it appears; the count keeps the value.
  useEffect(() => {
    if (!event) return;
    const timer = setTimeout(() => setEvent(null), 1100);
    return () => clearTimeout(timer);
  }, [event]);

  const text = formatCount(count);

  return (
    <div
      style={{
        width: 320,
        borderRadius: 18,
        overflow: "hidden",
        border: `1px solid ${tone(12)}`,
        background: tone(4),
      }}
    >
      <div
        style={{
          position: "relative",
          height: 132,
          padding: 12,
          display: "flex",
          alignItems: "flex-start",
          justifyContent: "space-between",
          // Stands in for the video frame, so it stays a literal gradient.
          background:
            "linear-gradient(150deg,#20304F 0%,#3A2E63 55%,#54305C 100%)",
          color: "#fff",
        }}
      >
        {/* Stands in for the picture itself: the shared canvas and the
            host's camera bubble, drawn as a faint wireframe so the
            badges stay the subject. Behind both badges by paint order —
            they are positioned, this svg's siblings must be too. */}
        <svg
          width="100%"
          height="100%"
          viewBox="0 0 320 132"
          fill="none"
          aria-hidden
          preserveAspectRatio="none"
          style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
        >
          <rect x="34" y="48" width="104" height="62" rx="6" stroke="#FFFFFF" strokeOpacity="0.28" strokeWidth="1.5" />
          <rect x="48" y="62" width="42" height="4" rx="2" fill="#FFFFFF" fillOpacity="0.24" />
          <rect x="48" y="72" width="28" height="4" rx="2" fill="#FFFFFF" fillOpacity="0.16" />
          <rect x="48" y="84" width="20" height="14" rx="3" stroke="#FFFFFF" strokeOpacity="0.22" strokeWidth="1.3" />
          <rect x="74" y="84" width="20" height="14" rx="3" stroke="#FFFFFF" strokeOpacity="0.22" strokeWidth="1.3" />
          <path
            d="M148 88v13l3.6-3 2.4 5.4 3-1.3-2.3-5.2 4.8-.5Z"
            fill="#FFFFFF"
            fillOpacity="0.34"
          />
          <circle cx="276" cy="98" r="17" stroke="#FFFFFF" strokeOpacity="0.3" strokeWidth="1.5" />
          <circle cx="276" cy="93.5" r="5.2" fill="#FFFFFF" fillOpacity="0.26" />
          <path d="M265.5 110.5a10.5 8.5 0 0 1 21 0" fill="#FFFFFF" fillOpacity="0.26" />
        </svg>

        <span
          style={{
            position: "relative",
            display: "flex",
            alignItems: "center",
            gap: 7,
            padding: "5px 10px 5px 8px",
            borderRadius: 999,
            background: "rgba(0,0,0,0.42)",
            fontSize: 11,
            fontWeight: 700,
            letterSpacing: 0.6,
          }}
        >
          <span style={{ position: "relative", display: "grid", placeItems: "center", width: 8, height: 8 }}>
            {/* One ring per arrival: the badge reacts to something that
                happened instead of ticking on its own forever. */}
            {!reduceMotion && event && (
              <motion.span
                key={event.id}
                aria-hidden
                initial={{ scale: 0.7, opacity: 0.6 }}
                animate={{ scale: cfg.ring, opacity: 0 }}
                transition={{ duration: cfg.ringSeconds, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: 0,
                  borderRadius: "50%",
                  border: `1.5px solid ${accent}`,
                }}
              />
            )}
            <span
              style={{
                width: 8,
                height: 8,
                borderRadius: "50%",
                background: accent,
              }}
            />
          </span>
          LIVE
        </span>

        <span
          style={{
            position: "relative",
            display: "flex",
            alignItems: "center",
            gap: 6,
            padding: "5px 10px",
            borderRadius: 999,
            background: "rgba(0,0,0,0.42)",
            fontSize: 12,
            fontWeight: 600,
          }}
        >
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden>
            <path
              d="M2.6 12S6.4 5.8 12 5.8 21.4 12 21.4 12 17.6 18.2 12 18.2 2.6 12 2.6 12Z"
              stroke="currentColor"
              strokeWidth="1.7"
              strokeLinejoin="round"
            />
            <circle cx="12" cy="12" r="2.8" stroke="currentColor" strokeWidth="1.7" />
          </svg>
          <span
            aria-live="polite"
            aria-label={`${text} watching`}
            style={{ display: "inline-flex", fontVariantNumeric: "tabular-nums" }}
          >
            {text.split("").map((char, index) => (
              <Slot
                key={`${index}-${text.length}`}
                char={char}
                seconds={reduceMotion ? 0 : cfg.roll}
                travel={!reduceMotion}
              />
            ))}
          </span>

          {/* The size of the jump, floating off. Translation and opacity
              only — figures never scale. */}
          <AnimatePresence>
            {event && (
              <motion.span
                key={event.id}
                aria-hidden
                initial={{ opacity: 0, y: 2 }}
                animate={{ opacity: 1, y: reduceMotion ? 0 : -12 }}
                exit={{ opacity: 0, y: reduceMotion ? 0 : -18 }}
                transition={{ duration: reduceMotion ? 0.18 : 0.5, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  top: -4,
                  right: 8,
                  fontSize: 11,
                  fontWeight: 700,
                  color: "#8CE0B4",
                  textShadow: "0 1px 6px rgba(0,0,0,0.4)",
                }}
              >
                +{event.joined}
              </motion.span>
            )}
          </AnimatePresence>
        </span>
      </div>

      <div style={{ padding: "12px 14px 13px" }}>
        <div style={{ fontSize: 14, fontWeight: 650, lineHeight: 1.3 }}>{title}</div>
        <div style={{ fontSize: 11.5, opacity: 0.52, marginTop: 3 }}>
          {host} · started 12 minutes ago
        </div>
      </div>
    </div>
  );
}

/** One character of the count. Digits roll in a fixed slot — the old
 *  figure leaves upward, the new one arrives from below, both at one type
 *  size — while separators simply sit there. */
function Slot({
  char,
  seconds,
  travel,
}: {
  char: string;
  seconds: number;
  travel: boolean;
}) {
  const isDigit = char >= "0" && char <= "9";
  if (!isDigit) {
    return <span style={{ display: "inline-block", width: "0.3em" }}>{char}</span>;
  }
  return (
    <span
      style={{
        position: "relative",
        display: "inline-block",
        width: "0.62em",
        height: 15,
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={char}
          initial={{ y: travel ? "100%" : 0, opacity: 0 }}
          animate={{ y: "0%", opacity: 1 }}
          exit={{ y: travel ? "-100%" : 0, opacity: 0 }}
          transition={{ duration: seconds, ease: [0.32, 0.72, 0, 1] }}
          style={{
            position: "absolute",
            inset: 0,
            lineHeight: "15px",
            textAlign: "center",
          }}
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

About this pattern

A live audience number is only interesting when it changes, so the motion is tied to arrivals rather than to a clock: each batch of people joining sends exactly one ring out of the badge and rolls the digits that actually changed. A badge that pulses forever spends attention it will need later, when something genuinely happens — this one is quiet between events. The digits roll on a tween in fixed-width tabular slots, never a spring, because numerals that overshoot and settle back read as a fault rather than as energy, and the row around them must not reflow while the number grows. The floating delta is the only flourish, and it is translation and opacity alone.

Live stream viewer countAudience joining a broadcastConcurrent users badgeLive event attendance

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Social feed

    A red live badge sits beside a viewer count that updates while the stream runs.

Related patterns