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

Rotating Words

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

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

Пропсы

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

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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/rotating-words.json
Что нужно в проекте до установки
  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 { useEffect, useRef, useState } from "react";

export interface RotatingWordsProps {
  /** Постоянная часть строки. */
  prefix?: string;
  /** Слова, которые сменяют друг друга. */
  words?: string[];
  /** Сколько слово держится на экране, мс (формат «1900»). */
  hold?: number;
}

const WORDS = ["интерфейсы", "лендинги", "витрины", "порталы"];

/**
 * Ротатор слов: рамка обжимается по ширине текущего слова, само слово уезжает вверх,
 * следующее приходит снизу. Ширина анимируется через inline-size конкретного значения
 * (меряем скрытый эталон), поэтому строка не «прыгает» и текст рядом не дёргается.
 * `prefers-reduced-motion` — слова просто сменяются без движения.
 */
export function RotatingWords({ prefix = "делаем", words = WORDS, hold = 1900 }: RotatingWordsProps) {
  const [i, setI] = useState(0);
  const [width, setWidth] = useState<number | null>(null);
  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(() => setI((v) => (v + 1) % words.length), hold);
    return () => clearInterval(id);
  }, [words.length, hold]);

  // ширину берём у скрытого эталона того же шрифта — без чтения layout в анимации
  useEffect(() => {
    const el = measure.current?.children[i] as HTMLElement | undefined;
    if (el) setWidth(el.offsetWidth);
  }, [i, words]);

  return (
    <span className="rtw">
      <style>{`
        .rtw { display: inline-flex; align-items: baseline; gap: 0.32em; font-weight: 700;
          letter-spacing: -0.03em; }
        .rtw-slot { position: relative; display: inline-block; overflow: hidden;
          vertical-align: bottom; height: 1.15em;
          transition: width 0.45s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .rtw-word { position: absolute; left: 0; top: 0; white-space: nowrap;
          background: linear-gradient(92deg, oklch(0.8 0.16 300), oklch(0.78 0.15 220));
          -webkit-background-clip: text; background-clip: text; color: transparent;
          animation: rtw-in 0.5s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
        @keyframes rtw-in { from { transform: translateY(105%); opacity: 0; } }
        .rtw-measure { position: absolute; visibility: hidden; pointer-events: none;
          white-space: nowrap; height: 0; overflow: hidden; }
        @media (prefers-reduced-motion: reduce) {
          .rtw-slot { transition: none; } .rtw-word { animation: none; }
        }
      `}</style>
      <span>{prefix}</span>
      <span className="rtw-slot" style={{ width: width ? `${width}px` : "auto" }}>
        <span key={i} className="rtw-word">
          {words[i]}
        </span>
      </span>
      <span ref={measure} className="rtw-measure" aria-hidden>
        {words.map((w) => (
          <span key={w}>{w}</span>
        ))}
      </span>
    </span>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0