Subframe
Components & interactionsOdometer digit counter

Digits on a roll

Individual digits take the shortest path around a spring-driven number wheel.

Update the idea count

Source: Motion Primitives

Add to your project

Component source

Standalone React component. Styles are included.

npm install [email protected]

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * Adapted from https://github.com/ibelick/motion-primitives/blob/120f64f6ca60348e251f929e9c81f11ccbe45eda/components/core/sliding-number.tsx
 * Standalone composition; inline styles and artwork; bounded motion and reduced-motion behavior.
 *
 * MIT License
 *
 * Copyright (c) 2024 ibelick
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { motion, type MotionValue, useSpring, useTransform } from "motion/react"
import { useEffect, useState } from "react"

// Read after hydration; subscribe so an OS preference change updates this instance.
function useReducedMotion() {
  const [reduced, setReduced] = useState<boolean | null>(null)
  useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(media.matches)
    update()
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])
  return reduced
}

function NumberRow({ value, number }: { value: MotionValue<number>; number: number }) {
  const y = useTransform(value, (latest) => {
    const placeValue = latest % 10
    const offset = (10 + number - placeValue) % 10
    return `${(offset > 5 ? offset - 10 : offset) * 100}%`
  })
  return (
    <motion.span
      style={{
        y,
        position: "absolute",
        inset: 0,
        height: "1em",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {number}
    </motion.span>
  )
}

function Digit({ value, place, canAnimate }: { value: number; place: number; canAnimate: boolean }) {
  const rounded = Math.floor(value / place) % 10
  const animated = useSpring(rounded, { stiffness: 280, damping: 22, mass: 0.3 })
  useEffect(() => {
    if (canAnimate) animated.set(rounded)
    else animated.jump(rounded)
  }, [rounded, animated, canAnimate])
  return (
    <span
      style={{
        display: "inline-block",
        position: "relative",
        width: "1ch",
        height: "1em",
        overflow: "hidden",
        fontVariantNumeric: "tabular-nums",
        lineHeight: 1,
      }}
    >
      {Array.from({ length: 10 }, (_, number) => (
        <NumberRow key={number} value={animated} number={number} />
      ))}
    </span>
  )
}

const values = [248, 372, 516, 409]

export default function SlidingNumber({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const [index, setIndex] = useState(0)
  const [keyboard, setKeyboard] = useState(false)
  const value = values[index]
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        minHeight: 260,
        display: "grid",
        gridTemplateColumns: "minmax(0, 1fr)",
        placeItems: "center",
        background: "#e3e9e9",
        color: "#3d5558",
        fontFamily: "inherit",
      }}
    >
      <div style={{ width: 232, maxWidth: "calc(100% - 48px)" }}>
        <output
          aria-live="polite"
          aria-label={`${value} new ideas collected`}
          style={{ display: "flex", alignItems: "baseline", gap: 12 }}
        >
          <span
            aria-hidden="true"
            style={{ display: "flex", fontSize: 72, fontWeight: 400, letterSpacing: "-.055em", lineHeight: 1 }}
          >
            {[100, 10, 1].map((place) => (
              <Digit key={place} value={value} place={place} canAnimate={active && reduced === false && !keyboard} />
            ))}
          </span>
          <span aria-hidden="true" style={{ fontSize: 11, color: "#566667" }}>
            ideas
          </span>
        </output>
        <div
          style={{
            display: "flex",
            justifyContent: "flex-end",
            alignItems: "center",
            borderTop: "1px solid #c9d6d4",
            marginTop: 22,
          }}
        >
          <button
            type="button"
            aria-label="Update idea count"
            onClick={(event) => {
              setKeyboard(event.detail === 0)
              setIndex((current) => (current + 1) % values.length)
            }}
            style={{
              width: 44,
              height: 44,
              padding: 0,
              background: "transparent",
              color: "#4d6c6d",
              border: 0,
              borderRadius: 8,
              fontSize: 20,
              cursor: "pointer",
            }}
          >
            ↻
          </button>
        </div>
      </div>
    </div>
  )
}

Where it works well

Show occasional changes in a small metric, score or count.

Reduced motion

Digits change immediately to their target. Only the target count is exposed to assistive technology.

Things to know

This compact sample is a three-digit integer counter. Add explicit sign, decimal and digit-count handling for other data.

More examples