Number Odometer

Scroll-triggered rolling counter that animates digits like a mechanical odometer. Works with any number format including currency symbols, commas, periods, and suffixes. Supports custom start values, stagger order, and programmatic updates.

GSAPScrollTriggerNumbersCounterScroll

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
ScrollTrigger CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<div data-odometer-group>
  <h1 data-odometer-element data-odometer-duration="2" data-odometer-start="€0" class="odometer-h1">€248.750</h1>
</div>
styles.css
css
.odometer-h1 {
  margin-top: 0;
  margin-bottom: 0;
  font-family: Haffer, Arial, sans-serif;
  font-size: 8vw;
  font-weight: 600;
  line-height: 1;
}

[data-odometer-element] {
  display: inline-flex;
  align-items: center;
  font-variant-numeric: tabular-nums;
}

[data-odometer-part="mask"],
[data-odometer-part="static"] {
  display: inline-block;
  overflow: clip;
  padding: 0.05em;
  margin: -0.05em;
}

[data-odometer-part="roller"] {
  display: block;
  white-space: pre;
  text-align: center;
  will-change: transform;
}

[data-odometer-part="static"] {
  display: inline-block;
}
script.js
javascript
function initNumberOdometer() {
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const initFlag = 'data-odometer-initialized'
  const activeTweens = new WeakMap()

  const defaults = {
    duration: 1,
    ease: 'power3.out',
    elementStagger: 0.1,
    digitStagger: 0.04,
    revealDuration: 0.5,
    revealEase: 'power2.out',
    triggerStart: 'top 80%',
    staggerOrder: 'left',
    digitCycles: 2
  }

  document.querySelectorAll('[data-odometer-group]').forEach(group => {
    if (group.hasAttribute(initFlag)) return
    group.setAttribute(initFlag, '')

    const elements = Array.from(group.querySelectorAll('[data-odometer-element]'))
    if (!elements.length || prefersReducedMotion) return

    const staggerOrder   = group.getAttribute('data-odometer-stagger-order') || defaults.staggerOrder
    const triggerStart   = group.getAttribute('data-odometer-trigger-start') || defaults.triggerStart
    const elementStagger = parseFloat(group.getAttribute('data-odometer-stagger')) || defaults.elementStagger

    const elementData = elements.map(el => {
      const originalText    = el.textContent.trim()
      const hasExplicitStart = el.hasAttribute('data-odometer-start')
      const startValue      = parseFloat(el.getAttribute('data-odometer-start')) || 0
      const duration        = parseFloat(el.getAttribute('data-odometer-duration')) || defaults.duration
      const step            = getLineHeightRatio(el)

      let segments = parseSegments(originalText)
      segments = mapStartDigits(segments, startValue)
      segments = markHiddenSegments(segments, startValue)

      const grow = shouldGrow(el, hasExplicitStart, startValue, segments)
      const { rollers, revealEls } = buildRollerDOM(el, segments, step, grow)

      const fontSize   = parseFloat(getComputedStyle(el).fontSize)
      const revealData = revealEls.map(revealEl => {
        const widthEm = revealEl.offsetWidth / fontSize
        gsap.set(revealEl, { width: 0, overflow: 'hidden' })
        return { el: revealEl, widthEm }
      })

      return { el, rollers, duration, step, revealData, originalText }
    })

    const ordered = applyStaggerOrder(elementData, staggerOrder)

    const tl = gsap.timeline({
      scrollTrigger: { trigger: group, start: triggerStart, once: true },
      onComplete() {
        elementData.forEach(({ el, originalText }) => cleanupElement(el, originalText))
      }
    })

    ordered.forEach((data, orderIdx) => {
      const { rollers, duration, step, revealData } = data
      const offset = orderIdx * elementStagger

      revealData.forEach(({ el, widthEm }) => {
        tl.to(el, { width: widthEm + 'em', opacity: 1, duration: defaults.revealDuration, ease: defaults.revealEase }, offset)
      })

      rollers.forEach(({ roller, targetPos }, digitIdx) => {
        const reversedIdx = rollers.length - 1 - digitIdx
        tl.to(roller, { y: -targetPos * step + 'em', duration, ease: defaults.ease, force3D: true }, offset + reversedIdx * defaults.digitStagger)
      })
    })
  })

  // Programmatic update
  return function updateOdometer(el, newText, options = {}) {
    const currentText = el.textContent.trim()
    if (currentText === newText) return

    const duration = options.duration || defaults.duration
    const ease     = options.ease     || defaults.ease
    const step     = getLineHeightRatio(el)

    const existing = activeTweens.get(el)
    if (existing) { existing.kill(); gsap.set(el, { clearProps: 'width,overflow' }) }

    const fontSize    = parseFloat(getComputedStyle(el).fontSize)
    const oldWidthEm  = el.getBoundingClientRect().width / fontSize

    const startSegments  = parseSegments(currentText)
    const startDigitsStr = startSegments.filter(s => s.type === 'digit').map(s => s.char).join('')
    const startValue     = parseInt(startDigitsStr, 10) || 0

    let segments = parseSegments(newText)
    segments = mapStartDigits(segments, startValue)
    segments = markHiddenSegments(segments, startValue)
    const { rollers, revealEls } = buildRollerDOM(el, segments, step, true)

    const newWidthEm    = el.getBoundingClientRect().width / fontSize
    const widthChanged  = Math.abs(oldWidthEm - newWidthEm) > 0.01
    if (widthChanged) gsap.set(el, { width: oldWidthEm + 'em', overflow: 'hidden' })

    const tl = gsap.timeline({ onComplete() { cleanupElement(el, newText); activeTweens.delete(el) } })
    activeTweens.set(el, tl)

    if (widthChanged) tl.to(el, { width: newWidthEm + 'em', duration: defaults.revealDuration, ease: defaults.revealEase }, 0)

    revealEls.forEach(revealEl => {
      if (revealEl.getAttribute('data-odometer-part') === 'static') tl.to(revealEl, { opacity: 1, duration: 0.2 }, 0)
    })

    rollers.forEach(({ roller, targetPos }, digitIdx) => {
      const reversedIdx = rollers.length - 1 - digitIdx
      tl.to(roller, { y: -targetPos * step + 'em', duration, ease, force3D: true }, reversedIdx * defaults.digitStagger)
    })
  }

  // Helpers
  function getLineHeightRatio(el) {
    const cs = getComputedStyle(el)
    const lh = cs.lineHeight
    if (lh === 'normal') return 1.2
    return parseFloat(lh) / parseFloat(cs.fontSize)
  }

  function parseSegments(text) {
    return [...text].map(char => ({ type: /\d/.test(char) ? 'digit' : 'static', char }))
  }

  function mapStartDigits(segments, startValue) {
    const digitSlots = segments.filter(s => s.type === 'digit')
    const padded = String(Math.floor(Math.abs(startValue))).padStart(digitSlots.length, '0').slice(-digitSlots.length)
    let di = 0
    return segments.map(s => s.type === 'digit' ? { ...s, startDigit: parseInt(padded[di++], 10) } : s)
  }

  function markHiddenSegments(segments, startValue) {
    const totalDigits   = segments.filter(s => s.type === 'digit').length
    const absStart      = Math.floor(Math.abs(startValue))
    const startDigitCount = absStart === 0 ? 1 : String(absStart).length
    const leadingZeros  = Math.max(0, totalDigits - startDigitCount)
    if (leadingZeros === 0) return segments
    let digitsSeen = 0, firstDigitSeen = false, prevDigitHidden = false
    return segments.map(seg => {
      if (seg.type === 'digit') {
        firstDigitSeen = true
        const hidden = digitsSeen < leadingZeros
        prevDigitHidden = hidden
        digitsSeen++
        return { ...seg, hidden }
      }
      const hidden = firstDigitSeen && prevDigitHidden
      return { ...seg, hidden }
    })
  }

  function shouldGrow(el, hasExplicitStart, startValue, segments) {
    if (el.hasAttribute('data-odometer-grow')) return el.getAttribute('data-odometer-grow') !== 'false'
    if (!hasExplicitStart) return false
    const absStart = Math.floor(Math.abs(startValue))
    const startDigitCount = absStart === 0 ? 1 : String(absStart).length
    const endDigitCount   = segments.filter(s => s.type === 'digit').length
    return startDigitCount < endDigitCount
  }

  function buildRollerDOM(el, segments, step, grow) {
    el.innerHTML = ''
    el.style.height = ''
    const rollers = [], revealEls = []
    const totalCells = 10 * defaults.digitCycles
    segments.forEach(seg => {
      if (seg.type === 'static') {
        const span = document.createElement('span')
        span.setAttribute('data-odometer-part', 'static')
        span.style.height = step + 'em'
        span.style.lineHeight = step
        span.textContent = seg.char
        el.appendChild(span)
        if (grow && seg.hidden) { gsap.set(span, { opacity: 0 }); revealEls.push(span) }
        return
      }
      const mask   = document.createElement('span')
      mask.setAttribute('data-odometer-part', 'mask')
      mask.style.height = step + 'em'
      mask.style.lineHeight = step
      const roller = document.createElement('span')
      roller.setAttribute('data-odometer-part', 'roller')
      roller.style.lineHeight = step
      const digits = []
      for (let d = 0; d < totalCells; d++) digits.push(d % 10)
      roller.textContent = digits.join('\n')
      mask.appendChild(roller)
      el.appendChild(mask)
      const startDigit = seg.startDigit || 0
      const isReveal   = grow && seg.hidden
      gsap.set(roller, { y: isReveal ? step + 'em' : -startDigit * step + 'em' })
      const endDigit  = parseInt(seg.char, 10)
      const targetPos = endDigit > startDigit ? endDigit : 10 + endDigit
      rollers.push({ roller, targetPos })
      if (isReveal) revealEls.push(mask)
    })
    return { rollers, revealEls }
  }

  function cleanupElement(el, originalText) {
    el.style.overflow = ''
    el.style.height   = ''
    const digits = [...originalText].filter(c => /\d/.test(c))
    let di = 0
    el.querySelectorAll('[data-odometer-part="mask"]').forEach(mask => {
      const roller = mask.querySelector('[data-odometer-part="roller"]')
      if (roller) roller.remove()
      mask.textContent   = digits[di++] || ''
      mask.style.opacity = ''
      mask.style.overflow = ''
    })
    el.querySelectorAll('[data-odometer-part="static"]').forEach(stat => { stat.style.opacity = '' })
  }

  function recalcOnResize() {
    document.querySelectorAll('[data-odometer-element]').forEach(el => {
      const running = activeTweens.get(el)
      if (running) { running.progress(1); activeTweens.delete(el) }
      const hasRollers = el.querySelector('[data-odometer-part="roller"]')
      if (hasRollers) {
        const step = getLineHeightRatio(el)
        el.querySelectorAll('[data-odometer-part="mask"]').forEach(m => { m.style.height = step + 'em'; m.style.lineHeight = step })
        el.querySelectorAll('[data-odometer-part="roller"]').forEach(r => { r.style.lineHeight = step })
        el.querySelectorAll('[data-odometer-part="static"]').forEach(s => { s.style.lineHeight = step })
      }
    })
    ScrollTrigger.refresh()
  }

  let resizeTimer, lastWidth = window.innerWidth
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer)
    resizeTimer = setTimeout(() => {
      if (window.innerWidth === lastWidth) return
      lastWidth = window.innerWidth
      recalcOnResize()
    }, 250)
  })

  function applyStaggerOrder(items, order) {
    const arr = [...items]
    if (order === 'right') return arr.reverse()
    if (order === 'random') return shuffleArray(arr)
    return arr
  }

  function shuffleArray(arr) {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1))
      ;[arr[i], arr[j]] = [arr[j], arr[i]]
    }
    return arr
  }
}

document.addEventListener("DOMContentLoaded", () => {
  initNumberOdometer();
})

Notes

  • Any non-digit character (comma, period, currency symbol, letter) is treated as a static character and stays in place while digits roll.
  • If `prefers-reduced-motion` is enabled, animations are skipped and end values display immediately.
  • Calling `initNumberOdometer()` multiple times is safe — already-initialised groups are skipped automatically, making it compatible with Barba.js and similar routers.
  • After the animation completes, roller DOM elements are removed and replaced with the final digit text, keeping the DOM clean.
  • Width transitions during digit-count changes are em-based so they scale correctly when the viewport resizes.

Guide

Group

Add `data-odometer-group` to any parent that wraps your odometer numbers. This acts as the scroll trigger — all elements inside animate together with a staggered delay when the group enters the viewport.

<div data-odometer-group>
  <h2 data-odometer-element>1,250</h2>
  <h2 data-odometer-element>500</h2>
  <h2 data-odometer-element>99%</h2>
</div>

Start value

Use `data-odometer-start` to define a custom starting number. If the start has fewer digits than the end, the extra columns expand into view automatically.

<h2 data-odometer-element data-odometer-start="900">1,250+</h2>

Stagger order & timing

Use `data-odometer-stagger-order` on the group (`left`, `right`, or `random`) and `data-odometer-stagger` to set the delay in seconds between elements.

<div data-odometer-group data-odometer-stagger-order="right" data-odometer-stagger="0.15">
  <p data-odometer-element>250</p>
  <p data-odometer-element>1,500</p>
</div>

Programmatic updates

Store the return value of `initNumberOdometer()` to get an `updateOdometer(el, newText, options?)` function. Call it to animate to a new value from JavaScript at any time, e.g. after filtering results.

const updateOdometer = initNumberOdometer()
const el = document.querySelector('.my-counter')
updateOdometer(el, '€2,500+')
updateOdometer(el, '10,482', { duration: 1.5, ease: 'power3.out' })