All patterns

Video Buffer Ring

The frame dims and a turning arc holds over it, then dissolves outward on resume.

loadingminimalcalmautomatic · looping · starter · ~0.9s
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.

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

/**
 * Vibary · Video Buffer Ring
 *
 * A stall, handled honestly: the frame dims, a ring turns over it while
 * the buffered range keeps creeping forward, and on resume the ring
 * dissolves outward instead of snapping off.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The frame is synthesized from CSS gradients — no poster asset — and
 * the surrounding chrome is mixed from the inherited text color, so the
 * player reads on a light page and on a dark one.
 * Works with zero props; pass `buffering` to drive it from your player's
 * `waiting` and `playing` events.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type VideoBufferRingProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** True while the player is stalled. Left undefined, the component
   *  stalls for `bufferMs` and then resumes on its own. */
  buffering?: boolean;
  /** Only consulted while `buffering` is undefined. */
  bufferMs?: number;
  /** Poster frame photograph; omit for the gradient stand-in. */
  posterSrc?: string;
  /** Frame width in px. Height follows a 16:9 ratio. */
  width?: number;
  /** Ring color. A literal accent over imagery, not a surface. */
  accent?: string;
  /** Announced while the player is stalled. */
  label?: string;
};

type VariantConfig = {
  /** How dark the frame goes while stalled, 0–1. */
  dim: number;
  dimSeconds: number;
  /** Seconds for one full turn of the ring. */
  turnSeconds: number;
  /** Fraction of the circumference the arc covers, 0–100. */
  arc: number;
  /** How far the ring expands as it dissolves. */
  dissolveScale: number;
  dissolveSeconds: number;
  ringSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the turn is linear and constant — an eased spin reads as
// a stutter, which is the one thing a buffering indicator must not
// suggest. Nothing else on the frame moves except the two scrub ranges,
// and the entrance spring sits above critical damping.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Shallow dim, short arc, quick turn. For a small inline player where
  // a stall should be noted rather than announced.
  subtle: {
    dim: 0.3,
    dimSeconds: 0.2,
    turnSeconds: 0.78,
    arc: 22,
    dissolveScale: 1.2,
    dissolveSeconds: 0.24,
    ringSpring: { type: "spring", stiffness: 560, damping: 44 },
  },
  // A readable dim and a full-weight ring. The all-purpose setting.
  default: {
    dim: 0.42,
    dimSeconds: 0.26,
    turnSeconds: 0.9,
    arc: 28,
    dissolveScale: 1.35,
    dissolveSeconds: 0.3,
    ringSpring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A deeper dim and a wider dissolve — for a theatre-mode player where
  // the frame is the whole screen.
  playful: {
    dim: 0.55,
    dimSeconds: 0.32,
    turnSeconds: 1.05,
    arc: 34,
    dissolveScale: 1.55,
    dissolveSeconds: 0.36,
    ringSpring: { type: "spring", stiffness: 400, damping: 36 },
  },
};

const ACCENT = "#FFFFFF";

/** Theme-adaptive neutral for the chrome around the frame. The frame's own
 *  colors stay literal: they stand in for a video still, which is imagery
 *  rather than a surface, and the controls painted over it are white
 *  alphas for the same reason. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** The still, built from gradients so the file carries no poster image. */
const FRAME_ART = [
  "radial-gradient(70% 52% at 22% 18%, rgba(255,236,200,0.34) 0%, transparent 62%)",
  "radial-gradient(58% 44% at 84% 86%, rgba(124,124,240,0.32) 0%, transparent 66%)",
  "linear-gradient(163deg, #23294A 0%, #3A3663 38%, #6B4A73 70%, #B0746A 100%)",
].join(", ");

export default function VideoBufferRing({
  variant = "default",
  buffering,
  posterSrc,
  bufferMs = 2200,
  width = 320,
  accent = ACCENT,
  label = "Buffering",
}: VideoBufferRingProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [selfBuffering, setSelfBuffering] = useState(true);

  // Uncontrolled by default so the file runs on its own; the moment a
  // caller passes `buffering`, this timer stays out of the way.
  useEffect(() => {
    if (buffering !== undefined) return;
    const timer = setTimeout(() => setSelfBuffering(false), bufferMs);
    return () => clearTimeout(timer);
  }, [buffering, bufferMs]);

  const stalled = buffering ?? selfBuffering;
  const height = Math.round((width * 9) / 16);
  const ring = Math.round(width * 0.13);

  return (
    <div style={{ width }}>
      <div
        style={{
          position: "relative",
          width,
          height,
          borderRadius: 12,
          overflow: "hidden",
          backgroundImage: posterSrc
            ? `url(${posterSrc}), ${FRAME_ART}`
            : FRAME_ART,
          backgroundSize: "cover",
          backgroundPosition: "center",
          border: `1px solid ${tone(12)}`,
        }}
      >
        {/* The dim is the state change; the ring only names it. Fading a
            scrim rather than blurring the frame keeps the picture readable
            and keeps the work on the compositor. */}
        <motion.div
          aria-hidden
          initial={false}
          animate={{ opacity: stalled ? cfg.dim : 0 }}
          transition={{ duration: cfg.dimSeconds, ease: "easeOut" }}
          style={{
            position: "absolute",
            inset: 0,
            background: "#080A10",
          }}
        />

        <div
          role="status"
          aria-label={stalled ? label : "Playing"}
          style={{
            position: "absolute",
            inset: 0,
            display: "grid",
            placeItems: "center",
          }}
        >
          <motion.div
            initial={false}
            animate={{
              opacity: stalled ? 1 : 0,
              // On resume the ring expands as it goes, so the stall ends
              // with a release rather than a cut.
              scale: stalled ? 1 : reduceMotion ? 1 : cfg.dissolveScale,
            }}
            transition={{
              opacity: {
                duration: stalled ? cfg.dimSeconds : cfg.dissolveSeconds,
                ease: "easeOut",
              },
              scale: reduceMotion
                ? { duration: 0 }
                : stalled
                  ? cfg.ringSpring
                  : { duration: cfg.dissolveSeconds, ease: "easeOut" },
            }}
            style={{ width: ring, height: ring }}
          >
            {/* Reduced motion: the arc holds still. The dim, the frozen
                scrub range and the announced status already say "stalled";
                only the turn is dropped. */}
            <motion.svg
              width={ring}
              height={ring}
              viewBox="0 0 40 40"
              fill="none"
              animate={reduceMotion ? undefined : { rotate: 360 }}
              transition={
                reduceMotion
                  ? undefined
                  : {
                      // Linear and constant: an eased turn reads as a
                      // stutter, which is exactly the wrong message here.
                      duration: cfg.turnSeconds,
                      repeat: Infinity,
                      ease: "linear",
                    }
              }
              style={{ display: "block" }}
            >
              <circle
                cx="20"
                cy="20"
                r="16"
                stroke={accent}
                strokeOpacity="0.28"
                strokeWidth="3"
              />
              <circle
                cx="20"
                cy="20"
                r="16"
                stroke={accent}
                strokeWidth="3"
                strokeLinecap="round"
                pathLength={100}
                strokeDasharray={`${cfg.arc} ${100 - cfg.arc}`}
              />
            </motion.svg>
          </motion.div>
        </div>

        <div
          aria-hidden
          style={{
            position: "absolute",
            left: 12,
            right: 12,
            bottom: 11,
          }}
        >
          <div
            style={{
              position: "relative",
              height: 3,
              borderRadius: 3,
              background: "rgba(255,255,255,0.24)",
              overflow: "hidden",
            }}
          >
            {/* Buffered range: it keeps creeping while stalled, which is the
                truthful part — bytes are still arriving. */}
            <motion.div
              initial={{ scaleX: 0.42 }}
              animate={{ scaleX: stalled ? 0.74 : 0.88 }}
              transition={{
                duration: reduceMotion ? 0 : stalled ? 2.4 : 0.5,
                ease: "linear",
              }}
              style={{
                position: "absolute",
                inset: 0,
                transformOrigin: "left center",
                borderRadius: 3,
                background: "rgba(255,255,255,0.42)",
              }}
            />
            {/* Played range: frozen for as long as the player is stalled.
                A playhead that keeps moving during a stall is a lie. */}
            <motion.div
              initial={{ scaleX: 0.3 }}
              animate={{ scaleX: stalled ? 0.3 : 0.52 }}
              transition={{
                duration: reduceMotion ? 0 : stalled ? 0 : 4,
                ease: "linear",
              }}
              style={{
                position: "absolute",
                inset: 0,
                transformOrigin: "left center",
                borderRadius: 3,
                background: "#FFFFFF",
              }}
            />
          </div>
        </div>
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          marginTop: 9,
          fontSize: 11.5,
        }}
      >
        {/* Both labels share one cell, so the line reserves the wider of
            them and the timecode beside it never shifts. */}
        <span
          aria-hidden
          style={{ display: "grid", justifyItems: "start", whiteSpace: "nowrap" }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: stalled ? 0.72 : 0 }}
            transition={{ duration: cfg.dimSeconds, ease: "easeOut" }}
            style={{ gridArea: "1 / 1" }}
          >
            {label}
          </motion.span>
          <motion.span
            initial={false}
            animate={{ opacity: stalled ? 0 : 0.55 }}
            transition={{ duration: cfg.dimSeconds, ease: "easeOut" }}
            style={{ gridArea: "1 / 1" }}
          >
            Product tour · 1080p
          </motion.span>
        </span>
        <span
          style={{
            opacity: 0.5,
            fontVariantNumeric: "tabular-nums",
            fontFeatureSettings: '"tnum"',
          }}
        >
          1:04 / 3:38
        </span>
      </div>
    </div>
  );
}

About this pattern

A stall handled honestly. A scrim fades over the still — a dim rather than a blur, so the picture stays readable and the work stays on the compositor — and an arc turns above it at a constant linear rate, because an eased turn reads as a stutter, which is the one thing a buffering indicator must never suggest. Underneath, the buffered range keeps creeping forward while the played range is frozen solid: bytes are arriving, the playhead is not moving, and both facts are visible at a glance. On resume the arc expands slightly as it fades, so the stall ends with a release instead of a cut. Reduced motion keeps the dim, the frozen playhead and the announced status, and stops the turn.

Video player stallLive stream reconnectAudio bufferingEmbedded product tour

Where it shows up

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

  • 4:12 / 9:48
    Media player

    Playback stalls behind a dimmed frame while the buffered range keeps extending.

Related patterns