← каталог
Blur Mask Reveal
Строки текста проявляются на скролле из блюра и клип-маски (scrub): из расфокуса в резкость.
gsaplenisscrollrevealintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| lines? | string[] | LINES |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/blur-mask-reveal.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 LINES = [
"Хороший интерфейс не кричит.",
"Он проявляется ровно тогда,",
"когда взгляд к нему готов —",
"из расфокуса в резкость.",
];
/**
* Строки текста проявляются на скролле из блюра и маски (blur + clip-path + y), каждая — своим
* триггером. Уважает reduced-motion / data-shot (текст сразу резкий).
*/
export function BlurMaskReveal({ lines = LINES }: { lines?: string[] }) {
const root = useRef<HTMLDivElement>(null);
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 rows = el.querySelectorAll<HTMLElement>(".bmr-line");
if (frozen) {
rows.forEach((r) => {
r.style.opacity = "1";
r.style.filter = "none";
r.style.clipPath = "inset(0)";
r.style.transform = "none";
});
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 ctx = gsap.context(() => {
rows.forEach((row) => {
gsap.fromTo(
row,
{ opacity: 0, y: 34, filter: "blur(14px)", clipPath: "inset(0 0 100% 0)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
clipPath: "inset(0 0 -10% 0)",
ease: "power2.out",
scrollTrigger: { trigger: row, start: "top 82%", end: "top 45%", scrub: true },
},
);
});
}, el);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}, [lines]);
return (
<div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
<section style={{ height: "50vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>крути вниз ↓</span>
</section>
<section style={{ minHeight: "150vh", paddingInline: "8vw" }}>
{lines.map((l) => (
<p
key={l}
className="bmr-line"
style={{
margin: "0 0 22vh",
fontSize: "clamp(1.4rem, 1rem + 2.4vw, 2.4rem)",
fontWeight: 700,
letterSpacing: "-0.02em",
maxWidth: "36ch",
willChange: "transform, filter, clip-path, opacity",
}}
>
{l}
</p>
))}
</section>
<section style={{ height: "35vh", 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";
/**
* Blur Mask Reveal — vanilla JS/GSAP+Lenis (без React). Строки [data-bmr] наводятся
* на резкость по скроллу: blur + clip-маска + сдвиг (scrub).
*
* <p data-bmr>Первая строка</p>
* <p data-bmr>Вторая строка</p>
* <script type="module">
* import { initBlurMaskReveal } from './blur-mask-reveal.vanilla.js';
* const destroy = initBlurMaskReveal(document.body); // или контейнер строк
* </script>
*
* @param {HTMLElement} root — контейнер со строками [data-bmr]
* @returns {() => void} destroy
*/
export function initBlurMaskReveal(root) {
const rows = [...root.querySelectorAll("[data-bmr]")];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) return () => {};
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 ctx = gsap.context(() => {
rows.forEach((row) => {
row.style.willChange = "transform, filter, clip-path, opacity";
gsap.fromTo(
row,
{ opacity: 0, y: 34, filter: "blur(14px)", clipPath: "inset(0 0 100% 0)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
clipPath: "inset(0 0 -10% 0)",
ease: "power2.out",
scrollTrigger: { trigger: row, start: "top 82%", end: "top 45%", scrub: true },
},
);
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0