← каталог
Text Reveal Scrub
Манифест-текст: слова проявляются по одному в такт прогрессу скролла (scrub); назад — «стираются» обратно.
gsaplenisscrollrevealintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | TEXT |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/text-reveal-scrub.jsonзависимости: gsap@^3.15.0 · lenis@^1.3.25
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Text Reveal Scrub — vanilla JS/GSAP+Lenis (без React). Слова абзаца загораются в такт
* прогрессу скролла (назад — «стираются»). Split по словам делается автоматически.
*
* <p id="manifesto">Детали — это не мелочи. Детали и есть дизайн.</p>
* <script type="module">
* import { initTextRevealScrub } from './text-reveal-scrub.vanilla.js';
* const destroy = initTextRevealScrub(document.querySelector('#manifesto'));
* </script>
*
* @param {HTMLElement} el — текстовый блок (контент будет пересобран в span-ы)
* @param {{ start?: string, end?: string, dimOpacity?: number }} [options]
* @returns {() => void} destroy (возвращает исходный текст)
*/
export function initTextRevealScrub(el, { start = "top 70%", end = "bottom 65%", dimOpacity = 0.13 } = {}) {
const original = el.textContent ?? "";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) return () => {};
el.textContent = "";
const spans = original.split(/\s+/).map((w) => {
const s = document.createElement("span");
s.style.cssText = `display:inline-block;opacity:${dimOpacity};transition:opacity 0.12s linear;`;
s.textContent = w;
el.append(s, " ");
return s;
});
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.1 });
lenis.on("scroll", ScrollTrigger.update);
const rafFn = (t) => lenis.raf(t * 1000);
gsap.ticker.add(rafFn);
gsap.ticker.lagSmoothing(0);
const st = ScrollTrigger.create({
trigger: el,
start,
end,
scrub: true,
onUpdate: (self) => {
spans.forEach((s, i) => {
s.style.opacity = i / spans.length < self.progress ? "1" : String(dimOpacity);
});
},
});
return () => {
st.kill();
gsap.ticker.remove(rafFn);
lenis.destroy();
el.textContent = original;
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-philosophy)v1.0.0