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

Text Reveal Scrub

Манифест-текст: слова проявляются по одному в такт прогрессу скролла (scrub); назад — «стираются» обратно.

gsaplenisscrollrevealintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
text?stringTEXT

Установка

npx shadcn@latest add https://motiva.pages.dev/r/text-reveal-scrub.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  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 } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

const TEXT =
  "Мы не просто собираем сайты — мы проектируем цифровые эмоции. Каждый скролл, каждый ховер, каждый переход — разговор бренда с человеком. Детали — это не мелочи. Детали и есть дизайн.";

/**
 * Манифест-текст: слова проявляются по одному, привязаны к прогрессу скролла (scrub).
 * Скролл назад «стирает» слова обратно. Уважает reduced-motion / data-shot (весь текст видим).
 */
export function TextRevealScrub({ text = TEXT }: { text?: string }) {
  const root = useRef<HTMLDivElement>(null);
  const words = text.split(/\s+/);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
    const spans = el.querySelectorAll<HTMLElement>(".trs-word");

    if (frozen) {
      spans.forEach((w) => w.classList.add("is-visible"));
      return;
    }

    gsap.registerPlugin(ScrollTrigger);
    const lenis = new Lenis({ duration: 1.1 });
    lenis.on("scroll", ScrollTrigger.update);
    const rafFn = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(rafFn);
    gsap.ticker.lagSmoothing(0);

    const st = ScrollTrigger.create({
      trigger: el.querySelector(".trs-body"),
      start: "top 70%",
      // конец достижим до упора скролла (аутро всего 40vh) — иначе последние слова не загорятся
      end: "bottom 65%",
      scrub: true,
      onUpdate: (self) => {
        spans.forEach((w, i) => w.classList.toggle("is-visible", i / spans.length < self.progress));
      },
    });

    return () => {
      st.kill();
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, [text]);

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
      <style>{`
        .trs-word { display: inline-block; opacity: 0.13; transition: opacity 0.12s linear; }
        .trs-word.is-visible { opacity: 1; }
      `}</style>
      <section style={{ height: "45vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>крути вниз ↓</span>
      </section>
      <section className="trs-body" style={{ minHeight: "120vh", display: "flex", alignItems: "center" }}>
        <p
          style={{
            margin: 0,
            paddingInline: "8vw",
            maxWidth: "62rem",
            fontSize: "clamp(1.5rem, 1rem + 2.6vw, 2.9rem)",
            fontWeight: 700,
            lineHeight: 1.3,
            letterSpacing: "-0.01em",
          }}
        >
          {words.map((w, i) => (
            <span key={i}>
              <span className="trs-word">{w}</span>{" "}
            </span>
          ))}
        </p>
      </section>
      <section style={{ height: "40vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
          конец — крути назад, слова «сотрутся»
        </span>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-philosophy)v1.0.0