Back to Code Bytes
3 min read
Number Pop-in

Description

Number Pop-in animates each character of a number independently as it updates, so counters, prices, and balances feel responsive rather than just swapping text. Each digit rises from below with a simultaneous blur-to-sharp entrance. The last two characters (typically a decimal separator and the trailing digit) stagger by --digit-stagger each, giving the decimal portion a slight lag that draws the eye and prevents all digits from arriving at once.

Example

$12.3

CSS

:root {
  --digit-dur: 500ms;
  --digit-distance: 8px;
  --digit-stagger: 70ms;
  --digit-blur: 2px;
  --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1);
  --digit-dir-x: 0;
  --digit-dir-y: 1;
}

@keyframes t-digit-pop-in {
  0% {
    transform: translate(
      calc(var(--digit-distance) * var(--digit-dir-x)),
      calc(var(--digit-distance) * var(--digit-dir-y))
    );
    opacity: 0;
    filter: blur(var(--digit-blur));
  }
  100% {
    transform: translate(0, 0);
    opacity: 1;
    filter: blur(0);
  }
}

.t-digit-group {
  display: inline-flex;
  align-items: baseline;
}
.t-digit {
  display: inline-block;
  will-change: transform, opacity, filter;
}
.t-digit-group.is-animating .t-digit {
  animation: t-digit-pop-in var(--digit-dur) var(--digit-ease) both;
}
.t-digit-group.is-animating .t-digit[data-stagger="1"] {
  animation-delay: var(--digit-stagger);
}
.t-digit-group.is-animating .t-digit[data-stagger="2"] {
  animation-delay: calc(var(--digit-stagger) * 2);
}

@media (prefers-reduced-motion: reduce) {
  .t-digit-group .t-digit {
    animation: none !important;
  }
}

React

import { useState } from "react";
import "./number-pop-in.css"; // paste the CSS above

function Digits({ value }: { value: string }) {
  const chars = value.split("");
  return (
    <span className="t-digit-group is-animating">
      {chars.map((ch, i) => {
        const stagger =
          i === chars.length - 2
            ? "1"
            : i === chars.length - 1
              ? "2"
              : undefined;
        return (
          <span key={i} className="t-digit" data-stagger={stagger}>
            {ch}
          </span>
        );
      })}
    </span>
  );
}

export function NumberPopIn() {
  const [value, setValue] = useState(12.3);
  const [key, setKey] = useState(0);
  const update = () => {
    setValue((v) => Math.round((v + 7.4) * 10) / 10);
    setKey((k) => k + 1);
  };
  return (
    <div>
      <span>
        $<Digits key={key} value={value.toFixed(1)} />
      </span>
      <button onClick={update}>Update number</button>
    </div>
  );
}

Variables

VariableDefaultNotes
--digit-dur500mssourced from --p9-dur
--digit-distance8pxsourced from --p9-distance
--digit-stagger70mssourced from --p9-stagger
--digit-blur2pxsourced from --p9-blur
--digit-easecubic-bezier(0.34, 1.45, 0.64, 1)sourced from --p9-ease
--digit-dir-x0sourced from --p9-dir-x
--digit-dir-y1sourced from --p9-dir-y

Credit

Adapted from Number Pop-in on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.