Rolling Text
Посимвольный барабан, как на табло аэропорта: при смене фразы каждый знак прокручивается своей колонкой через случайный промежуточный символ, волной слева направо. Родня rotating-words крутит слово целиком — здесь механика посимвольная. Прокрутка — transition на translate (композитор), волна — чистый transition-delay; между сменами не работает ничего, кроме таймера.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| interval? | number | 2.6 | Секунд между сменами. |
| duration? | number | 0.5 | Длительность прокрутки одного знака, секунды. |
| stagger? | number | 45 | Задержка между соседними знаками, мс — волна слева направо. |
| fontSize? | string | "inherit" | Кегль: любое CSS-значение font-size. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/rolling-text.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}