Transitions.dev

Home / Transitions

Spinning counter

Digits spin like a reel to the value

Open the live demo Install with the Skill

CSS

Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.

/* Transitions.dev — Spinning counter */

/*
  Usage:
    <div class="t-reel"></div>  <!-- reels built in JS -->

  Build one .t-reel-col per digit, each clipping a strip
  (.t-reel-strip) of 0-9 cells; translate the strip up by
  (spins*10 + digit) cells to spin then land. A directional
  (vertical-only) SVG feGaussianBlur stdDeviation="0 Y" gives
  the motion streak (CSS blur() would smear sideways); decay it
  to 0 per column as each reel settles.
*/

:root {
  --reel-dur: 1400ms;
  --reel-cell: 30px;
  --reel-spin-blur: 3px;
  --reel-stagger: 90ms;
  --reel-ease: cubic-bezier(0.16, 1, 0.3, 1);
}

.t-reel { display: inline-flex; align-items: center; height: var(--reel-cell); font-variant-numeric: tabular-nums; }
.t-reel-col {
  position: relative; height: var(--reel-cell); overflow: hidden;
  /* Soft-fade the window edges instead of hard-cropping. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
}
.t-reel-strip { display: flex; flex-direction: column; will-change: transform, filter; }
.t-reel-digit { height: var(--reel-cell); display: flex; align-items: center; justify-content: center; }
/* JS drives the tween: strip.style.transition =
     'transform var(--reel-dur) var(--reel-ease) ' + (col*var(--reel-stagger)) + 'ms';
   and decays each column's feGaussianBlur stdDeviation from
   var(--reel-spin-blur) to 0 over its own window. */

@media (prefers-reduced-motion: reduce) {
  .t-reel-strip { transition: none !important; filter: none !important; }
}

React

Self-contained component; the styles inject themselves on first import.

// Transitions.dev — Spinning counter (React, self-contained)
// Drop into any React project — no extra CSS file needed.

import { useEffect, useRef } from "react";

// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
:root {
  --reel-dur: 1400ms;
  --reel-cell: 30px;
  --reel-spin-blur: 3px;
  --reel-stagger: 90ms;
  --reel-ease: cubic-bezier(0.16, 1, 0.3, 1);
}

.t-reel { display: inline-flex; align-items: center; height: var(--reel-cell); font-variant-numeric: tabular-nums; }
.t-reel-col {
  position: relative; height: var(--reel-cell); overflow: hidden;
  /* Soft-fade the window edges instead of hard-cropping. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
}
.t-reel-strip { display: flex; flex-direction: column; will-change: transform, filter; }
.t-reel-digit { height: var(--reel-cell); display: flex; align-items: center; justify-content: center; }
/* JS drives the tween: strip.style.transition =
     'transform var(--reel-dur) var(--reel-ease) ' + (col*var(--reel-stagger)) + 'ms';
   and decays each column's feGaussianBlur stdDeviation from
   var(--reel-spin-blur) to 0 over its own window. */

@media (prefers-reduced-motion: reduce) {
  .t-reel-strip { transition: none !important; filter: none !important; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p26")) {
  const __style = document.createElement("style");
  __style.id = "transitions-p26";
  __style.textContent = __TRANSITION_STYLES;
  document.head.appendChild(__style);
}

// Pair with the CSS from the CSS tab. Each digit is a clipped reel of
// 0-9 cells; the strip translates up (spins*10 + digit) cells to spin then
// land, with a per-column delay for a left-to-right cascade. (The vertical
// SVG motion-blur driver is omitted here for brevity — see the demo.)
const SPINS = 3, CELL = 30, DUR = 1400, STAGGER = 90;
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";

export function Counter({ target = 100 }) {
  const ref = useRef(null);

  function build(str) {
    ref.current.innerHTML = "";
    const strips = [];
    [...str].forEach((ch) => {
      if (ch < "0" || ch > "9") {
        const sep = document.createElement("span");
        sep.className = "t-reel-sep";
        sep.textContent = ch;
        ref.current.appendChild(sep);
        return;
      }
      const col = document.createElement("span");
      col.className = "t-reel-col";
      const strip = document.createElement("span");
      strip.className = "t-reel-strip";
      for (let k = 0; k < (SPINS + 1) * 10 + 1; k++) {
        const cell = document.createElement("span");
        cell.className = "t-reel-digit";
        cell.textContent = String(k % 10);
        strip.appendChild(cell);
      }
      col.appendChild(strip);
      ref.current.appendChild(col);
      strips.push({ strip, digit: +ch });
    });
    return strips;
  }

  useEffect(() => {
    // Rest at 0.
    build("0").forEach(({ strip, digit }) => {
      strip.style.transform = `translateY(-${digit * CELL}px)`;
    });
  }, []);

  function spin() {
    const strips = build(Math.round(target).toLocaleString("en-US"));
    strips.forEach(({ strip }) => {
      strip.style.transition = "none";
      strip.style.transform = "translateY(0)";
    });
    void ref.current.offsetWidth;
    strips.forEach(({ strip, digit }, i) => {
      strip.style.transition = `transform ${DUR}ms ${EASE} ${i * STAGGER}ms`;
      strip.style.transform = `translateY(-${(SPINS * 10 + digit) * CELL}px)`;
    });
  }

  return (
    <>
      <div ref={ref} className="t-reel" aria-label={String(target)} />
      <button type="button" onClick={spin}>Animate</button>
    </>
  );
}