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

Rotating Words

Ротатор слов: рамка обжимается по ширине текущего слова, слово уезжает вверх, следующее приходит снизу. Строка не прыгает — ширина берётся у скрытого эталона.

cssautorevealbasic~2 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
prefix?string"делаем"Постоянная часть строки.
words?string[]WORDSСлова, которые сменяют друг друга.
hold?number1900Сколько слово держится на экране, мс.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/rotating-words.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";

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