Motiva
Мои коллекции
← каталог

Number Ticker

Число плавно «прокручивается» от нуля до значения при появлении. На Motion.

motionloadrevealbasic~33 КБreduced-motion ✓

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
valuenumber
duration?number1.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

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0