Parallax Panels
Панели-сервисы с разнослойным параллаксом: визуал едет внутри clip, крупный контурный номер — навстречу скроллу.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/parallax-panels.jsonзависимости: gsap@^3.15.0 · lenis@^1.3.25
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.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 PANELS = [
{
n: "01",
title: "Стратегия",
text: "Исследование, аудит, дорожная карта — фундамент до первого пикселя.",
hue: 280,
img: "https://motiva.pages.dev/img/1074-900x700.jpg",
},
{
n: "02",
title: "Дизайн",
text: "Система, типографика, движение. Форма как способ прочитать смысл.",
hue: 210,
img: "https://motiva.pages.dev/img/491-900x700.jpg",
},
{
n: "03",
title: "Разработка",
text: "Скорость, доступность, детали взаимодействия — продакшен без компромиссов.",
hue: 150,
img: "https://motiva.pages.dev/img/221-900x700.jpg",
},
];
/**
* Панели-сервисы с разнослойным параллаксом: визуал едет в clip вниз, крупный номер —
* навстречу скроллу (counter-scroll), чётные панели зеркалятся. Уважает reduced-motion / data-shot.
*/
export function ParallaxPanels() {
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;
if (frozen) 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(() => {
el.querySelectorAll<HTMLElement>(".pp-panel").forEach((panel) => {
const st = { trigger: panel, start: "top bottom", end: "bottom top", scrub: true } as const;
const art = panel.querySelector(".pp-art");
const num = panel.querySelector(".pp-num");
if (art) gsap.fromTo(art, { yPercent: -12 }, { yPercent: 12, ease: "none", scrollTrigger: { ...st } });
if (num) gsap.fromTo(num, { y: 46 }, { y: -64, ease: "none", scrollTrigger: { ...st } });
});
}, el);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}, []);
return (
<div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
<section style={{ height: "30vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>панели с параллаксом ↓</span>
</section>
{PANELS.map((p, i) => (
<section
key={p.n}
className="pp-panel"
style={{
minHeight: "78vh",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "5vw",
alignItems: "center",
paddingInline: "6vw",
direction: i % 2 ? "rtl" : "ltr",
}}
>
<div style={{ direction: "ltr", position: "relative" }}>
<span
className="pp-num"
aria-hidden
style={{
position: "absolute",
top: "-1.1em",
left: -8,
fontFamily: "ui-monospace, monospace",
fontSize: "clamp(3rem, 2rem + 5vw, 5.5rem)",
fontWeight: 700,
color: "transparent",
WebkitTextStroke: "1.5px oklch(0.45 0.03 270)",
willChange: "transform",
}}
>
{p.n}
</span>
<h3 style={{ margin: 0, fontSize: "1.7rem", fontWeight: 750, letterSpacing: "-0.02em", position: "relative" }}>
{p.title}
</h3>
<p style={{ margin: "0.7rem 0 0", fontSize: "0.95rem", lineHeight: 1.6, color: "oklch(0.7 0.02 265)" }}>
{p.text}
</p>
</div>
<div
style={{
direction: "ltr",
position: "relative",
aspectRatio: "4 / 3",
borderRadius: 16,
overflow: "hidden",
border: "1px solid oklch(0.3 0.02 270)",
}}
>
{/* фото крупнее контейнера — параллаксит внутри clip; демо-снимок каталога, замени на свой */}
<img
className="pp-art"
src={p.img}
alt={p.title}
loading="lazy"
style={{
position: "absolute",
inset: "-18% 0",
width: "100%",
height: "136%",
objectFit: "cover",
willChange: "transform",
}}
/>
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(160deg, oklch(0.4 0.1 ${p.hue} / 0.28), transparent 60%)`,
pointerEvents: "none",
}}
/>
</div>
</section>
))}
<section style={{ height: "25vh", 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";
/**
* Parallax Panels — vanilla JS/GSAP+Lenis (без React). Панели с разнослойным параллаксом:
* визуал едет внутри clip вниз, крупный номер — навстречу скроллу.
*
* <section data-pp-panel>
* <span data-pp-num>01</span>
* <div style="overflow:hidden"><img data-pp-art src="a.jpg" style="height:130%"></div>
* </section>
* <script type="module">
* import { initParallaxPanels } from './parallax-panels.vanilla.js';
* const destroy = initParallaxPanels(document.body); // или контейнер панелей
* </script>
*
* @param {HTMLElement} root — контейнер с [data-pp-panel]; внутри [data-pp-art] и [data-pp-num]
* @returns {() => void} destroy
*/
export function initParallaxPanels(root) {
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(() => {
root.querySelectorAll("[data-pp-panel]").forEach((panel) => {
const st = { trigger: panel, start: "top bottom", end: "bottom top", scrub: true };
const art = panel.querySelector("[data-pp-art]");
const num = panel.querySelector("[data-pp-num]");
if (art) gsap.fromTo(art, { yPercent: -12 }, { yPercent: 12, ease: "none", scrollTrigger: { ...st } });
if (num) gsap.fromTo(num, { y: 46 }, { y: -64, ease: "none", scrollTrigger: { ...st } });
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}