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

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

Что нужно в проекте до установки
  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 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0