← каталог
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
Что нужно в проекте до установки
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";
/**
* 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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Scroll Parallax — vanilla JS/GSAP+Lenis (без React). Оживляет существующую разметку:
* [data-speed] — параллакс-слои, [data-reveal] — reveal при входе во вьюпорт.
*
* <section id="hero">
* <div data-speed="2">фоновый слой</div>
* <h2 data-reveal>Заголовок</h2>
* </section>
* <script type="module">
* import { initScrollParallax } from './scroll-parallax.vanilla.js';
* const destroy = initScrollParallax(document.querySelector('#hero'));
* </script>
*
* @param {HTMLElement} root
* @returns {() => void} destroy
*/
export function initScrollParallax(root) {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
if (frozen) {
root.querySelectorAll("[data-reveal]").forEach((el) => {
el.style.opacity = "1";
el.style.transform = "none";
});
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(() => {
gsap.utils.toArray("[data-speed]", root).forEach((layer) => {
gsap.to(layer, {
yPercent: -parseFloat(layer.dataset.speed || "0") * 30,
ease: "none",
scrollTrigger: { trigger: root, start: "top top", end: "bottom bottom", scrub: true },
});
});
gsap.utils.toArray("[data-reveal]", root).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%" } },
);
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0