Rotating Words
Ротатор слов: рамка обжимается по ширине текущего слова, слово уезжает вверх, следующее приходит снизу. Строка не прыгает — ширина берётся у скрытого эталона.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| prefix? | string | "делаем" | Постоянная часть строки. |
| words? | string[] | WORDS | Слова, которые сменяют друг друга. |
| hold? | number | 1900 | Сколько слово держится на экране, мс. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/rotating-words.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";
const css = `.rtw { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 0.32em;
font-weight: 700; letter-spacing: -0.03em; }
.rtw-slot { position: relative; display: inline-block; overflow: clip;
line-height: 1.25;
transition: width 0.45s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.rtw-ghost { visibility: hidden; white-space: nowrap; }
.rtw-word { position: absolute; left: 0; top: 0; line-height: inherit; white-space: nowrap;
background: linear-gradient(92deg,
var(--ds-accent, oklch(0.72 0.16 300)),
var(--ds-accent-2, oklch(0.72 0.16 220)));
-webkit-background-clip: text; background-clip: text; color: transparent; }
.rtw-in { animation: rtw-in 0.5s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.rtw-out { animation: rtw-out 0.5s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
@keyframes rtw-in { from { transform: translateY(115%); opacity: 0; } }
@keyframes rtw-out { to { transform: translateY(-115%); opacity: 0; } }
.rtw-measure { position: absolute; visibility: hidden; pointer-events: none;
white-space: nowrap; height: 0; overflow: clip; }
@media (prefers-reduced-motion: reduce) {
.rtw-slot { transition: none; }
.rtw-in { animation: none; }
.rtw-out { display: none; }
}
`;
import { useEffect, useRef, useState } from "react";
export interface RotatingWordsProps {
/** Постоянная часть строки. */
prefix?: string;
/** Слова, которые сменяют друг друга. */
words?: string[];
/** Сколько слово держится на экране, мс. */
hold?: number;
}
const WORDS = ["интерфейсы", "лендинги", "витрины", "порталы"];
/**
* Ротатор слов: текущее слово уезжает вверх, следующее приходит снизу, рамка
* обжимается по ширине пришедшего. Смена — ПАРА анимаций (выход + вход): уходящее
* слово живёт в разметке до конца выезда, иначе ротации не видно — старое исчезает
* скачком. В потоке слота лежит невидимая копия текущего слова: она даёт слоту
* честный базлайн (флекс выравнивает по строке, а не по низу рамки), высоту
* с местом под нижние выносы (р/ф/у) и ширину до всякого JS. Ширина в px нужна
* только переходу: transition не анимирует auto. Первое слово рендерится без
* входной анимации — прыжка на загрузке нет. `prefers-reduced-motion` — слова
* просто сменяются без движения.
*/
export function RotatingWords({ prefix = "делаем", words = WORDS, hold = 1900 }: RotatingWordsProps) {
const [i, setI] = useState(0);
const [prev, setPrev] = useState<number | null>(null);
const [width, setWidth] = useState<number | null>(null);
const idx = useRef(0);
const measure = useRef<HTMLSpanElement>(null);
useEffect(() => {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return;
const id = setInterval(() => {
setPrev(idx.current);
idx.current = (idx.current + 1) % words.length;
setI(idx.current);
}, hold);
return () => clearInterval(id);
}, [words.length, hold]);
// уходящее слово живёт ровно на время анимации выхода
useEffect(() => {
if (prev === null) return;
const t = setTimeout(() => setPrev(null), 550);
return () => clearTimeout(t);
}, [prev, i]);
// ширину берём у скрытого эталона того же шрифта — без чтения layout в анимации
useEffect(() => {
const el = measure.current?.children[i] as HTMLElement | undefined;
if (el) setWidth(el.offsetWidth);
}, [i, words]);
return (
<span className="rtw">
<style href="motiva-rotating-words" precedence="medium">{css}</style>
<span>{prefix}</span>
<span className="rtw-slot" style={{ width: width === null ? undefined : `${width}px` }}>
<span className="rtw-ghost" aria-hidden="true">
{words[i]}
</span>
{prev !== null && (
<span key={`out-${prev}-${i}`} className="rtw-word rtw-out" aria-hidden="true">
{words[prev]}
</span>
)}
<span key={`in-${i}`} className={prev === null ? "rtw-word" : "rtw-word rtw-in"}>
{words[i]}
</span>
</span>
<span ref={measure} className="rtw-measure" aria-hidden="true">
{words.map((w) => (
<span key={w}>{w}</span>
))}
</span>
</span>
);
}