UI Components Medium
Number Ticker
An animated number display that counts up to a target value using independently sliding digit columns, creating a smooth slot-machine effect with easing.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #f1f5f9;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.ticker-wrapper {
width: min(600px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.ticker-heading {
font-size: 1.375rem;
font-weight: 700;
color: #f1f5f9;
}
.ticker-cards {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.ticker-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 1rem;
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
.ticker-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ticker-row {
display: flex;
align-items: baseline;
}
.ticker-prefix,
.ticker-suffix {
font-size: 1.75rem;
font-weight: 700;
color: #e2e8f0;
}
.ticker-suffix {
font-size: 1.25rem;
color: #64748b;
margin-left: 0.1rem;
}
/* ── Number ticker ── */
.number-ticker {
display: flex;
align-items: baseline;
overflow: hidden;
height: 1em;
font-size: 2rem;
font-weight: 700;
line-height: 1;
color: #f8fafc;
}
/* ── Digit column ── */
.digit-column {
display: flex;
flex-direction: column;
transition: transform 1s cubic-bezier(0.22, 1, 0.36, 1);
}
.digit-column span {
height: 1em;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Separator (comma) ── */
.digit-separator {
height: 1em;
display: flex;
align-items: center;
color: #475569;
font-size: 0.85em;
}
/* ── Reset button ── */
.ticker-reset {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
color: #a5b4fc;
font-size: 0.85rem;
font-weight: 500;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s ease;
}
.ticker-reset:hover {
background: rgba(99, 102, 241, 0.2);
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.digit-column {
transition: none;
}
}
/* ── Responsive ── */
@media (max-width: 480px) {
.ticker-cards {
grid-template-columns: 1fr;
}
.number-ticker {
font-size: 1.75rem;
}
}(function () {
"use strict";
var tickers = document.querySelectorAll(".number-ticker");
var resetBtn = document.getElementById("ticker-reset");
function buildTicker(el) {
var value = el.getAttribute("data-value") || "0";
var separator = el.getAttribute("data-separator") || "";
var digits = value.split("");
// Clear existing content
el.innerHTML = "";
// Calculate separator positions (counting from right)
var digitIndex = 0;
digits.forEach(function (digit, i) {
// Add separator before this digit if needed
if (separator && digitIndex > 0 && (digits.length - i) % 3 === 0) {
var sep = document.createElement("span");
sep.className = "digit-separator";
sep.textContent = separator;
el.appendChild(sep);
}
// Create digit column
var column = document.createElement("div");
column.className = "digit-column";
// Add digits 0-9
for (var d = 0; d <= 9; d++) {
var span = document.createElement("span");
span.textContent = d;
column.appendChild(span);
}
// Start at 0 (no transform)
column.style.transform = "translateY(0)";
column.setAttribute("data-target", digit);
// Stagger delay: rightmost digits animate first
var delay = (digits.length - 1 - i) * 0.06;
column.style.transitionDelay = delay + "s";
el.appendChild(column);
digitIndex++;
});
return el;
}
function animateTickers() {
tickers.forEach(function (el) {
var columns = el.querySelectorAll(".digit-column");
columns.forEach(function (col) {
var target = parseInt(col.getAttribute("data-target"), 10);
col.style.transform = "translateY(-" + target + "em)";
});
});
}
function resetTickers() {
tickers.forEach(function (el) {
var columns = el.querySelectorAll(".digit-column");
columns.forEach(function (col) {
col.style.transition = "none";
col.style.transform = "translateY(0)";
});
});
// Trigger reflow then re-animate
requestAnimationFrame(function () {
requestAnimationFrame(function () {
tickers.forEach(function (el) {
var columns = el.querySelectorAll(".digit-column");
columns.forEach(function (col) {
col.style.transition = "";
var target = parseInt(col.getAttribute("data-target"), 10);
col.style.transform = "translateY(-" + target + "em)";
});
});
});
});
}
// Build all tickers
tickers.forEach(buildTicker);
// Animate on load with slight delay
setTimeout(animateTickers, 200);
// Reset button
if (resetBtn) {
resetBtn.addEventListener("click", resetTickers);
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Number Ticker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="ticker-wrapper">
<h2 class="ticker-heading">Statistics</h2>
<div class="ticker-cards">
<div class="ticker-card">
<span class="ticker-label">Total Users</span>
<div class="number-ticker" data-value="48253" data-separator=","></div>
</div>
<div class="ticker-card">
<span class="ticker-label">Revenue</span>
<div class="ticker-row">
<span class="ticker-prefix">$</span>
<div class="number-ticker" data-value="127849" data-separator=","></div>
</div>
</div>
<div class="ticker-card">
<span class="ticker-label">Uptime</span>
<div class="ticker-row">
<div class="number-ticker" data-value="9998"></div>
<span class="ticker-suffix">%</span>
</div>
</div>
</div>
<button class="ticker-reset" id="ticker-reset">Replay Animation</button>
</div>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState, useMemo, useCallback } from "react";
interface NumberTickerProps {
value?: number;
separator?: string;
prefix?: string;
suffix?: string;
duration?: number;
}
function DigitColumn({
digit,
index,
totalDigits,
animate,
duration,
}: {
digit: number;
index: number;
totalDigits: number;
animate: boolean;
duration: number;
}) {
const delay = (totalDigits - 1 - index) * 0.06;
return (
<div
style={{
display: "flex",
flexDirection: "column",
transition: animate
? `transform ${duration}s cubic-bezier(0.22, 1, 0.36, 1) ${delay}s`
: "none",
transform: animate ? `translateY(-${digit}em)` : "translateY(0)",
}}
>
{Array.from({ length: 10 }, (_, d) => (
<span
key={d}
style={{
height: "1em",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{d}
</span>
))}
</div>
);
}
export default function NumberTicker({
value = 48253,
separator = ",",
prefix,
suffix,
duration = 1,
}: NumberTickerProps) {
const [animate, setAnimate] = useState(false);
const digits = useMemo(() => {
return String(value).split("").map(Number);
}, [value]);
useEffect(() => {
const timer = setTimeout(() => setAnimate(true), 200);
return () => clearTimeout(timer);
}, []);
const replay = useCallback(() => {
setAnimate(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimate(true);
});
});
}, []);
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#f1f5f9",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "2rem",
}}
>
<h2 style={{ fontSize: "1.375rem", fontWeight: 700 }}>Statistics</h2>
<div
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: "1rem",
padding: "2rem 2.5rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
fontSize: "0.8rem",
color: "#64748b",
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Total Count
</span>
<div style={{ display: "flex", alignItems: "baseline" }}>
{prefix && (
<span style={{ fontSize: "2rem", fontWeight: 700, color: "#e2e8f0" }}>{prefix}</span>
)}
<div
style={{
display: "flex",
alignItems: "baseline",
overflow: "hidden",
height: "1em",
fontSize: "2.5rem",
fontWeight: 700,
lineHeight: 1,
color: "#f8fafc",
}}
>
{digits.map((digit, i) => {
const needsSep = separator && i > 0 && (digits.length - i) % 3 === 0;
return (
<span key={i} style={{ display: "contents" }}>
{needsSep && (
<span
style={{
height: "1em",
display: "flex",
alignItems: "center",
color: "#475569",
fontSize: "0.85em",
}}
>
{separator}
</span>
)}
<DigitColumn
digit={digit}
index={i}
totalDigits={digits.length}
animate={animate}
duration={duration}
/>
</span>
);
})}
</div>
{suffix && (
<span style={{ fontSize: "1.25rem", color: "#64748b", marginLeft: "0.1rem" }}>
{suffix}
</span>
)}
</div>
</div>
<button
onClick={replay}
style={{
background: "rgba(99,102,241,0.1)",
border: "1px solid rgba(99,102,241,0.2)",
color: "#a5b4fc",
fontSize: "0.85rem",
fontWeight: 500,
padding: "0.5rem 1.25rem",
borderRadius: "0.5rem",
cursor: "pointer",
}}
>
Replay Animation
</button>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
value: { type: Number, default: 48253 },
separator: { type: String, default: "," },
prefix: { type: String, default: "" },
suffix: { type: String, default: "" },
duration: { type: Number, default: 1 },
});
const animate = ref(false);
const digits = computed(() => String(props.value).split("").map(Number));
onMounted(() => {
setTimeout(() => {
animate.value = true;
}, 200);
});
function replay() {
animate.value = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animate.value = true;
});
});
}
function needsSep(i) {
return props.separator && i > 0 && (digits.value.length - i) % 3 === 0;
}
function getDelay(i) {
return (digits.value.length - 1 - i) * 0.06;
}
function columnStyle(digit, i) {
const delay = getDelay(i);
return {
display: "flex",
flexDirection: "column",
transition: animate.value
? `transform ${props.duration}s cubic-bezier(0.22, 1, 0.36, 1) ${delay}s`
: "none",
transform: animate.value ? `translateY(-${digit}em)` : "translateY(0)",
};
}
</script>
<template>
<div class="ticker-demo">
<div class="ticker-inner">
<h2 class="ticker-heading">Statistics</h2>
<div class="ticker-card">
<span class="ticker-label">Total Count</span>
<div style="display: flex; align-items: baseline;">
<span v-if="prefix" class="ticker-prefix">{{ prefix }}</span>
<div class="ticker-digits">
<template v-for="(digit, i) in digits" :key="i">
<span v-if="needsSep(i)" class="ticker-sep">{{ separator }}</span>
<div :style="columnStyle(digit, i)">
<span v-for="d in 10" :key="d - 1" class="ticker-digit-cell">
{{ d - 1 }}
</span>
</div>
</template>
</div>
<span v-if="suffix" class="ticker-suffix">{{ suffix }}</span>
</div>
</div>
<button class="ticker-replay" @click="replay">Replay Animation</button>
</div>
</div>
</template>
<style scoped>
.ticker-demo {
min-height: 100vh;
background: #0a0a0a;
display: grid;
place-items: center;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
color: #f1f5f9;
}
.ticker-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.ticker-heading {
font-size: 1.375rem;
font-weight: 700;
}
.ticker-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 1rem;
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.ticker-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ticker-prefix {
font-size: 2rem;
font-weight: 700;
color: #e2e8f0;
}
.ticker-digits {
display: flex;
align-items: baseline;
overflow: hidden;
height: 1em;
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
color: #f8fafc;
}
.ticker-sep {
height: 1em;
display: flex;
align-items: center;
color: #475569;
font-size: 0.85em;
}
.ticker-digit-cell {
height: 1em;
display: flex;
align-items: center;
justify-content: center;
}
.ticker-suffix {
font-size: 1.25rem;
color: #64748b;
margin-left: 0.1rem;
}
.ticker-replay {
background: rgba(99,102,241,0.1);
border: 1px solid rgba(99,102,241,0.2);
color: #a5b4fc;
font-size: 0.85rem;
font-weight: 500;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
cursor: pointer;
}
</style><script>
import { onMount } from "svelte";
export let value = 48253;
export let separator = ",";
export let prefix = "";
export let suffix = "";
export let duration = 1;
let animate = false;
$: digits = String(value).split("").map(Number);
onMount(() => {
const timer = setTimeout(() => {
animate = true;
}, 200);
return () => clearTimeout(timer);
});
function replay() {
animate = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animate = true;
});
});
}
function needsSep(i) {
return separator && i > 0 && (digits.length - i) % 3 === 0;
}
function getDelay(i) {
return (digits.length - 1 - i) * 0.06;
}
</script>
<div style="min-height: 100vh; background: #0a0a0a; display: grid; place-items: center; padding: 2rem; font-family: system-ui, -apple-system, sans-serif; color: #f1f5f9;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 2rem;">
<h2 style="font-size: 1.375rem; font-weight: 700;">Statistics</h2>
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 1rem; padding: 2rem 2.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.8rem; color: #64748b; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em;">
Total Count
</span>
<div style="display: flex; align-items: baseline;">
{#if prefix}
<span style="font-size: 2rem; font-weight: 700; color: #e2e8f0;">{prefix}</span>
{/if}
<div style="display: flex; align-items: baseline; overflow: hidden; height: 1em; font-size: 2.5rem; font-weight: 700; line-height: 1; color: #f8fafc;">
{#each digits as digit, i}
{#if needsSep(i)}
<span style="height: 1em; display: flex; align-items: center; color: #475569; font-size: 0.85em;">
{separator}
</span>
{/if}
<div
style="display: flex; flex-direction: column; transition: {animate ? `transform ${duration}s cubic-bezier(0.22, 1, 0.36, 1) ${getDelay(i)}s` : 'none'}; transform: {animate ? `translateY(-${digit}em)` : 'translateY(0)'};"
>
{#each Array(10) as _, d}
<span style="height: 1em; display: flex; align-items: center; justify-content: center;">
{d}
</span>
{/each}
</div>
{/each}
</div>
{#if suffix}
<span style="font-size: 1.25rem; color: #64748b; margin-left: 0.1rem;">{suffix}</span>
{/if}
</div>
</div>
<button
on:click={replay}
style="background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: #a5b4fc; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1.25rem; border-radius: 0.5rem; cursor: pointer;"
>
Replay Animation
</button>
</div>
</div>Number Ticker
An animated slot-machine style number ticker that counts to a target value by sliding individual digit columns. Each digit animates independently with staggered timing, creating a satisfying mechanical feel.
How it works
- The target number is split into individual digits.
- Each digit gets its own column containing elements for 0-9, stacked vertically.
- CSS
transform: translateY()slides each column to show the correct digit. - Staggered
transition-delayon each column creates a cascade effect from right to left.
Features
- Independent digit column animation
- Configurable target value
- Smooth cubic-bezier easing
- Supports commas/separators
- Respects
prefers-reduced-motion