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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>Code
<div data-odometer-group>
<h1 data-odometer-element data-odometer-duration="2" data-odometer-start="€0" class="odometer-h1">€248.750</h1>
</div>.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;
}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' })