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

Scroll Parallax

Scroll-driven секция: плавный скролл на Lenis + parallax-слои и reveal на GSAP ScrollTrigger.

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

Установка

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

/**
 * Scroll-driven секция: Lenis (плавный скролл) + GSAP ScrollTrigger (parallax-слои + reveal).
 * Уважает `prefers-reduced-motion` и `?noanim`/data-shot (статичное финальное состояние).
 */
export function ScrollParallax() {
  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;

    gsap.registerPlugin(ScrollTrigger);
    let lenis: Lenis | null = null;
    let rafFn: ((time: number) => void) | null = null;

    const ctx = gsap.context(() => {
      if (frozen) {
        gsap.set(".sp-reveal", { opacity: 1, y: 0 });
        return;
      }

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

      gsap.utils.toArray<HTMLElement>("[data-speed]").forEach((layer) => {
        gsap.to(layer, {
          yPercent: -parseFloat(layer.dataset.speed || "0") * 30,
          ease: "none",
          scrollTrigger: { trigger: el, start: "top top", end: "bottom bottom", scrub: true },
        });
      });

      gsap.utils.toArray<HTMLElement>(".sp-reveal").forEach((item) => {
        gsap.fromTo(
          item,
          { opacity: 0, y: 40 },
          { opacity: 1, y: 0, duration: 0.7, ease: "power2.out", scrollTrigger: { trigger: item, start: "top 82%" } },
        );
      });
    }, el);

    return () => {
      ctx.revert();
      if (rafFn) gsap.ticker.remove(rafFn);
      lenis?.destroy();
    };
  }, []);

  const cards = ["Parallax", "Scroll-driven", "Smooth (Lenis)", "Reveal"];

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
      <section
        style={{ position: "relative", height: "70vh", display: "grid", placeItems: "center", overflow: "hidden" }}
      >
        <div
          data-speed="2"
          style={{
            position: "absolute",
            inset: "-20% 0",
            background: "radial-gradient(50% 50% at 50% 40%, oklch(0.62 0.19 280 / 0.5), transparent 70%)",
          }}
        />
        <div data-speed="-1" style={{ textAlign: "center" }}>
          <div style={{ fontSize: "1.9rem", fontWeight: 700, letterSpacing: "-0.02em" }}>Scroll Parallax</div>
          <div style={{ marginTop: 8, fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.6 }}>
            крути вниз ↓
          </div>
        </div>
      </section>

      <section style={{ display: "grid", gap: "14px", padding: "6vh 8vw" }}>
        {cards.map((t) => (
          <div
            key={t}
            className="sp-reveal"
            style={{
              padding: "1.1rem 1.4rem",
              borderRadius: 14,
              border: "1px solid oklch(0.3 0.02 270)",
              background: "oklch(0.18 0.02 270)",
              fontWeight: 600,
            }}
          >
            {t}
          </div>
        ))}
      </section>

      <section style={{ height: "45vh", display: "grid", placeItems: "center" }}>
        <p className="sp-reveal" style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.85rem", opacity: 0.7 }}>
          Lenis · GSAP ScrollTrigger
        </p>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0