Back to the library

Number ticker — count up

Variant: Count up

The one that is an entrance rather than an update — a stat landing on a dashboard for the first time. A registered `<integer>` custom property is animated and rendered through counter(), so the value itself is the animated thing and no script drives a loop; the deceleration is not authored anywhere, it falls out of putting an expo-out curve on the number. Left-aligned in a reserved three-digit box, because a centred count-up shifts the whole value twice on the way up as it gains digits.

datacounterentrance

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Number ticker — count up</title>
    <style>
      /* A registered integer is the whole trick: the browser interpolates it,
         rounds it every frame, and counter() renders the result — so the value
         itself is the animated property and no script has to drive a loop. */
      @property --_n {
        syntax: '<integer>';
        initial-value: 0;
        inherits: false;
      }

      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        --_muted: color-mix(in srgb, var(--slate) 45%, transparent);

        /* Clock — 4.6s: a 322ms arrival, a 1.15s count, a long hold, a 198ms
           exit, and a dead beat before it re-arms. The hold is most of the loop
           because this is an entrance — the tile spends its life settled. */
        --cycle: 4.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* Near-exponential decay. The deceleration of the count is not authored
           anywhere — it falls out of applying this curve to the number itself,
           so the first 200ms cover most of the range and the last hundred land
           one at a time, which is what counting to something looks like. */
        --ease-accent: cubic-bezier(0.16, 1, 0.3, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .tile {
        display: grid;
        gap: 10px;
        justify-items: start;
        padding: 18px 22px 20px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
        animation: arrive var(--t) infinite;
      }

      .label {
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      .value {
        font-size: clamp(36px, 11vw, 46px);
        font-weight: 600;
        font-variant-numeric: tabular-nums;
      }

      /* Left-aligned inside a reserved three-digit box. Every digit that
         appears keeps the column it appeared in, so the number grows rightwards
         and nothing already on screen moves; centre or right alignment would
         shift the whole value twice on the way up as it gains digits. */
      .num {
        display: inline-block;
        min-width: 3ch;
        animation:
          count var(--t) infinite,
          settle var(--t) infinite;
        counter-reset: n var(--_n);
      }

      .num::after {
        content: counter(n);
      }

      @keyframes count {
        0%,
        4% {
          --_n: 0;
          animation-timing-function: var(--ease-accent);
        }
        29%,
        100% {
          --_n: 300;
        }
      }

      /* Clay while the number is still moving, released over 400ms once it
         lands. The colour is what says the count is finished — the digits stop
         changing a few frames before the eye is sure they have. */
      @keyframes settle {
        0%,
        29% {
          color: var(--clay-d);
          animation-timing-function: var(--ease-out);
        }
        37.7%,
        100% {
          color: var(--slate);
        }
      }

      @keyframes arrive {
        0%,
        4% {
          opacity: 0;
          transform: translateY(8px);
          animation-timing-function: var(--ease-out);
        }
        11%,
        86% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        90.3%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The count stays — it is a value changing, not a thing moving, and
           removing it would leave the tile making no claim at all. The 8px rise
           goes, and the cycle stretches to 7.4s so the digits resolve at a pace
           that can be read rather than absorbed. */
        .tile {
          animation-name: arrive-still;
          animation-duration: calc(var(--t) * 1.6);
        }

        .num {
          animation-duration: calc(var(--t) * 1.6), calc(var(--t) * 1.6);
        }

        @keyframes arrive-still {
          0%,
          4% {
            opacity: 0;
          }
          11%,
          86% {
            opacity: 1;
          }
          90.3%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="tile"
      role="img"
      aria-label="A metric tile arriving on a dashboard, its value counting up from zero and decelerating onto 300 active sessions"
    >
      <span class="label">Active sessions</span>
      <span class="value"><span class="num"></span></span>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.

Number ticker — count up - Animations