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

Word Fade Scroll

Абзац проявляется по словам вслед за прокруткой (приём длинных манифестов Apple/Linear): один именованный view()-таймлайн абзаца, у каждого слова свой отрезок пути через animation-range инлайн-переменными от индекса. Ноль скриптов, переносы строк не важны.

cssscrollrevealintermediatereduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
dim?number0.22Прозрачность непрочитанного слова (0—1).
spread?number1.6Насколько окна соседних слов перекрываются (в «словах»): 1 — встык, больше — мягче.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/word-fade-scroll.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

Код

const css = `.wfs {
  margin: 0;
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: var(--ds-tracking-tight, -0.02em);
  color: var(--ds-fg, oklch(0.95 0.01 265));
  /* один именованный таймлайн НА АБЗАЦ: слова-потомки находят его по имени.
     Свой view() у каждого слова не годится: у слов одной строки таймлайны
     совпадают, и строка вспыхивала бы целиком */
  view-timeline: --wfs block;
}

/* база: слово видно целиком — без поддержки таймлайнов, под reduce и data-shot
   абзац просто стоит прочитанным (правило «база вне @supports») */
@supports (animation-timeline: view()) {
  @media not (prefers-reduced-motion: reduce) {
    .wfs-w {
      opacity: var(--wfs-dim, 0.22);
      animation: wfs-in linear both;
      animation-timeline: --wfs;
      /* отрезок пути слова задан ИНДЕКСОМ при рендере (инлайн-переменные):
         никакой зависимости от строк и переносов — порядок чтения и есть порядок */
      animation-range: cover var(--wfs-r0, 10%) cover var(--wfs-r1, 60%);
    }
    @keyframes wfs-in {
      to { opacity: 1; }
    }
  }
}

html[data-shot] .wfs-w {
  animation: none;
  opacity: 1;
}
`;

export interface WordFadeScrollProps {
  /** Абзац; разбивается по пробелам. */
  text?: string;
  /** Прозрачность непрочитанного слова (0—1). */
  dim?: number;
  /** Насколько окна соседних слов перекрываются (в «словах»): 1 — встык, больше — мягче. */
  spread?: number;
}

const TEXT =
  "Хороший текст читают в темпе читателя, а не в темпе автора. Эта строка проявляется " +
  "по мере прокрутки: слово за словом, как взгляд идёт по абзацу. Отмотайте назад — " +
  "и прочитанное честно погаснет, потому что прогресс принадлежит странице, а не таймеру.";

/**
 * Абзац проявляется ПО СЛОВАМ вслед за прокруткой — приём длинных манифестов
 * Apple и Linear. Ноль скриптов: у абзаца один именованный view()-таймлайн,
 * а каждое слово получает свой отрезок пути через `animation-range`
 * инлайн-переменными, посчитанными от ИНДЕКСА слова при рендере.
 *
 * Разность приёма с соседями: `highlight-lines` ведёт маркер по СТРОКАМ
 * (отрезки зависят от переносов и меряются после раскладки), `blur-mask-reveal`
 * проявляет строки из расфокуса на GSAP+Lenis. Здесь гранула — слово, отрезки
 * чисто арифметические (переносы не важны), и всё это CSS без единого байта JS.
 */
export function WordFadeScroll({ text = TEXT, dim = 0.22, spread = 1.6 }: WordFadeScrollProps) {
  const words = text.trim().split(/\s+/);
  const n = Math.max(words.length, 1);
  // окно чтения: 12%–78% пути cover — края пути абзац проводит у кромок кадра,
  // где читать его никто не будет
  const start = 12;
  const span = 66;

  return (
    <p className="wfs" style={{ ["--wfs-dim" as string]: dim }}>
      <style href="motiva-word-fade-scroll" precedence="medium">{css}</style>
      {words.map((w, i) => {
        const r0 = start + (span * i) / n;
        const r1 = Math.min(start + (span * (i + spread)) / n, start + span);
        return (
          <span
            key={`${w}-${i}`}
            className="wfs-w"
            style={{
              ["--wfs-r0" as string]: `${r0.toFixed(2)}%`,
              ["--wfs-r1" as string]: `${r1.toFixed(2)}%`,
            }}
          >
            {w}
            {i < words.length - 1 ? " " : ""}
          </span>
        );
      })}
    </p>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); приём известен по манифестам Apple/Linear, реализация свояv1.0.0