← каталог
Number Ticker
Число плавно «прокручивается» от нуля до значения при появлении. На Motion.
motionloadrevealbasic~33 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| value | number | — | |
| duration? | number | 1.4 | |
| locale? | string | "ru-RU" | |
| decimals? | number | — | Знаков после запятой. По умолчанию выводится столько же, сколько у `value`: 99.98 останется 99,98, а 1200 — 1 200. Ставь явно, когда нужен фиксированный формат (цена — всегда два знака, счётчик — всегда ноль). |
| suffix? | string | — | Приписка сразу за числом: «%», «₽», «×». Живёт внутри, чтобы не разъезжаться. |
| className? | string | — |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/number-ticker.jsonзависимости: motion@^12.42.2
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { animate } from "motion";
import { useEffect, useRef } from "react";
export interface NumberTickerProps {
value: number;
duration?: number;
locale?: string;
/**
* Знаков после запятой. По умолчанию выводится столько же, сколько у `value`:
* 99.98 останется 99,98, а 1200 — 1 200. Ставь явно, когда нужен фиксированный
* формат (цена — всегда два знака, счётчик — всегда ноль).
*/
decimals?: number;
/** Приписка сразу за числом: «%», «₽», «×». Живёт внутри, чтобы не разъезжаться. */
suffix?: string;
className?: string;
}
/** Сколько знаков после запятой у числа: 99.98 → 2, 1200 → 0. */
const fractionDigits = (n: number) => (Number.isInteger(n) ? 0 : (String(n).split(".")[1]?.length ?? 0));
/**
* Число, «прокручивающееся» от 0 до value при появлении (Motion). Уважает reduced-motion / data-shot.
*
* Дробная часть сохраняется: раньше здесь стоял `Math.round`, и «99.98 %» аптайма
* превращалось в «100 %» — то есть счётчик врал ровно в тех величинах, ради которых
* его обычно и ставят (доли, рейтинги, цены). Разрядность берётся у самого значения,
* а `decimals` позволяет её зафиксировать.
*/
export function NumberTicker({
value,
duration = 1.4,
locale = "ru-RU",
decimals,
suffix,
className,
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const digits = decimals ?? fractionDigits(value);
const fmt = new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) {
el.textContent = fmt.format(value);
return;
}
// старт, когда счётчик реально виден (а не на маунте страницы) — иначе при быстрой
// загрузке анимация успевает отыграть «за кадром» и выглядит статикой
let controls: ReturnType<typeof animate> | null = null;
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
io.disconnect();
controls = animate(0, value, {
duration,
ease: "easeOut",
onUpdate: (v) => {
el.textContent = fmt.format(v);
},
});
},
{ threshold: 0.5 },
);
io.observe(el);
return () => {
io.disconnect();
controls?.stop();
};
}, [value, duration, locale, decimals]);
return (
<span className={className}>
<span ref={ref}>0</span>
{suffix}
</span>
);
}
import { animate } from "motion";
/** Сколько знаков после запятой у числа: 99.98 → 2, 1200 → 0. */
const fractionDigits = (n) => (Number.isInteger(n) ? 0 : (String(n).split(".")[1]?.length ?? 0));
/**
* Number Ticker — vanilla JS/Motion (без React).
*
* <span id="n">0</span>
* <script type="module">
* import { initNumberTicker } from './number-ticker.vanilla.js';
* initNumberTicker(document.querySelector('#n'), { value: 12480 });
* // дробное значение сохраняет разрядность: { value: 99.98 } → «99,98»
* </script>
*
* @param {HTMLElement} el
* @param {{ value: number, duration?: number, locale?: string, decimals?: number }} options
* @returns {() => void} destroy
*/
export function initNumberTicker(el, { value, duration = 1.4, locale = "ru-RU", decimals }) {
const digits = decimals ?? fractionDigits(value);
const fmt = new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) {
el.textContent = fmt.format(value);
return () => {};
}
let controls = null;
// старт, когда счётчик реально виден — иначе отыграет «за кадром»
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
io.disconnect();
controls = animate(0, value, {
duration,
ease: "easeOut",
onUpdate: (v) => {
el.textContent = fmt.format(v);
},
});
},
{ threshold: 0.5 },
);
io.observe(el);
return () => {
io.disconnect();
controls?.stop();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0