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

Rolling Text

Посимвольный барабан, как на табло аэропорта: при смене фразы каждый знак прокручивается своей колонкой через случайный промежуточный символ, волной слева направо. Родня rotating-words крутит слово целиком — здесь механика посимвольная. Прокрутка — transition на translate (композитор), волна — чистый transition-delay; между сменами не работает ничего, кроме таймера.

cssautointermediatereduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
interval?number2.6Секунд между сменами.
duration?number0.5Длительность прокрутки одного знака, секунды.
stagger?number45Задержка между соседними знаками, мс — волна слева направо.
fontSize?string"inherit"Кегль: любое CSS-значение font-size.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/rolling-text.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
const css = `.rlt {
  display: inline-flex;
  font-weight: 800;
  letter-spacing: var(--ds-tracking-tight, -0.03em);
  color: inherit;
}

/* окно одного знака. Ширину задаёт НЕВИДИМАЯ копия целевого глифа в потоке —
   без неё слот менял бы ширину от случайного промежуточного знака, и строка
   дёргалась бы весь спин */
.rlt-slot {
  position: relative;
  display: inline-block;
  height: 1.16em;
  /* клип НЕ overflow: у inline-block с overflow базлайн синтезируется по нижней
     кромке (грабля rotating-words), и окна съезжали вверх относительно строки.
     clip-path режет так же, а базлайн оставляет честный — от копии в потоке */
  clip-path: inset(0);
}
.rlt-size {
  visibility: hidden;
  white-space: pre;
  line-height: 1.16;
}

/* барабан: три знака колонкой (старый · случайный · новый), едет на −2 строки.
   Высота строки = высоте окна ЯВНО: унаследованный line-height был выше окна,
   и куски соседних глифов просвечивали над и под строкой */
.rlt-reel {
  position: absolute;
  inset: 0 0 auto 0;
  text-align: center;
  translate: 0 0;
}
.rlt-reel > span {
  display: block;
  height: 1.16em;
  line-height: 1.16;
  white-space: pre;
}
.rlt-slot[data-spin] .rlt-reel {
  translate: 0 -2.32em;
  transition: translate var(--rlt-dur, 0.5s) var(--ds-ease-in-out, cubic-bezier(0.65, 0, 0.35, 1));
  transition-delay: var(--rlt-delay, 0s);
}

@media (prefers-reduced-motion: reduce) {
  .rlt-slot[data-spin] .rlt-reel { transition: none; }
}
html[data-shot] .rlt-slot[data-spin] .rlt-reel { transition: none; }
`;

export interface RollingTextProps {
  /** Фразы барабана; короткие добиваются пробелами до самой длинной. */
  phrases?: string[];
  /** Секунд между сменами. */
  interval?: number;
  /** Длительность прокрутки одного знака, секунды. */
  duration?: number;
  /** Задержка между соседними знаками, мс — волна слева направо. */
  stagger?: number;
  /** Кегль: любое CSS-значение font-size. */
  fontSize?: string;
}

const PHRASES = ["дизайнеров", "разработки", "маркетинга", "продуктов"];

interface Reel {
  chars: [string, string, string];
}

/**
 * Посимвольный барабан, как на табло аэропорта: при смене фразы КАЖДЫЙ знак
 * прокручивается своей колонкой через случайный промежуточный символ, волной
 * слева направо. Родня `rotating-words` крутит слово целиком — здесь механика
 * именно посимвольная, и в этом весь характер.
 *
 * Колонка знака — три строки (старый · случайный · новый) в окне высотой в одну;
 * прокрутка — transition на `translate` (движение в композиторе), волна — чистый
 * `transition-delay` по номеру знака. JS только собирает колонки и переключает
 * атрибут; между сменами не работает ничего, кроме таймера.
 *
 * Под `prefers-reduced-motion` фразы сменяются мгновенно (transition снят в CSS),
 * под `data-shot` барабан не тикает вовсе. Вкладка скрыта или запись вне кадра —
 * таймер простаивает.
 */
export function RollingText({
  phrases = PHRASES,
  interval = 2.6,
  duration = 0.5,
  stagger = 45,
  fontSize = "inherit",
}: RollingTextProps = {}) {
  const host = useRef<HTMLSpanElement>(null);
  const [current, setCurrent] = useState(0);
  const [reels, setReels] = useState<Reel[] | null>(null);
  const [spin, setSpin] = useState(false);

  const len = Math.max(...phrases.map((p) => p.length));
  const pad = (s: string) => s.padEnd(len, " ");
  const alphabet = [...new Set(phrases.join("").replace(/\s/g, ""))];

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    if (document.documentElement.hasAttribute("data-shot")) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let visible = true;
    const io = new IntersectionObserver(([e]) => (visible = e!.isIntersecting));
    io.observe(el);

    let idx = 0;
    let cleanup: ReturnType<typeof setTimeout> | null = null;
    const id = setInterval(() => {
      if (document.hidden || !visible) return;
      const next = (idx + 1) % phrases.length;
      const from = pad(phrases[idx]!);
      const to = pad(phrases[next]!);
      if (reduce) {
        idx = next;
        setCurrent(next);
        return;
      }
      // колонки: старый знак, случайный промежуточный, новый — и волна атрибутом
      setReels(
        [...to].map((ch, i) => ({
          chars: [from[i]!, alphabet[Math.floor(Math.random() * alphabet.length)] ?? ch, ch],
        })),
      );
      setSpin(false);
      requestAnimationFrame(() => requestAnimationFrame(() => setSpin(true)));
      cleanup = setTimeout(
        () => {
          idx = next;
          setCurrent(next);
          setReels(null);
          setSpin(false);
        },
        duration * 1000 + stagger * len + 60,
      );
    }, Math.max(1.2, interval) * 1000);

    return () => {
      clearInterval(id);
      if (cleanup) clearTimeout(cleanup);
      io.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phrases.join("|"), interval, duration, stagger]);

  const shown = pad(phrases[current] ?? "");

  return (
    <span className="rlt" ref={host} style={{ fontSize, "--rlt-dur": `${duration}s` } as CSSProperties}>
      <style href="motiva-rolling-text" precedence="medium">{css}</style>
      {reels
        ? reels.map((r, i) => (
            <span key={i} className="rlt-slot" data-spin={spin ? "" : undefined} style={{ "--rlt-delay": `${i * stagger}ms` } as CSSProperties}>
              <span className="rlt-size" aria-hidden="true">{r.chars[2]}</span>
              <span className="rlt-reel">
                <span>{r.chars[0]}</span>
                <span>{r.chars[1]}</span>
                <span>{r.chars[2]}</span>
              </span>
            </span>
          ))
        : [...shown].map((ch, i) => (
            <span key={i} className="rlt-slot">
              <span className="rlt-size" aria-hidden="true">{ch}</span>
              <span className="rlt-reel">
                <span>{ch}</span>
              </span>
            </span>
          ))}
    </span>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — GSAP demo «Rolling text» (класс split-flap/одометра; код не заимствован)v1.0.0