← каталог
Sticky Stack
Стек секций: карточки липнут к верху и наезжают друг на друга, уходящая сжимается и темнеет.
gsaplenisscrollstaggerparallaxintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Установка
npx shadcn@latest add https://motiva.pages.dev/r/sticky-stack.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 CARDS = [
{ n: "01", title: "Исследование", hue: 280 },
{ n: "02", title: "Прототип", hue: 230 },
{ n: "03", title: "Дизайн", hue: 170 },
{ n: "04", title: "Запуск", hue: 60 },
];
const EDGE = 14; // кромка стопки, px — каждая прилипшая карточка остаётся видимой полоской сверху
/**
* Sticky-стек «стопка листов»: карточки липнут каскадом (видны кромки предыдущих),
* следующая ложится сверху с тенью, уходящая мягко отступает — деликатный scale
* + вуаль-оверлей с ease-кривой (НЕ filter: brightness — тот грязно темнит контент).
* Уважает reduced-motion / data-shot (обычный поток, стопка без анимаций).
*/
export function StickyStack() {
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(() => {
const cards = [...el.querySelectorAll<HTMLElement>(".ss-card")];
cards.forEach((card, i) => {
const next = cards[i + 1];
if (!next) return;
const st = { trigger: next, start: "top bottom", end: "top top", scrub: true } as const;
// уходящая деликатно отступает: ease почти не трогает её, пока следующая внизу,
// и мягко добирает к моменту полного наезда — никакого резкого потемнения
gsap.to(card, { scale: 0.965, ease: "power1.in", scrollTrigger: { ...st } });
const veil = card.querySelector(".ss-veil");
if (veil) gsap.to(veil, { opacity: 0.45, ease: "power2.in", 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: "35vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>крути — листы лягут стопкой ↓</span>
</section>
{/* общий контейнер: каждая карточка липнет чуть ниже предыдущей (видны кромки стопки),
следующая (позже в потоке) ложится сверху с тенью */}
<div>
{CARDS.map((c, i) => (
<section
key={c.n}
className="ss-card"
style={{
position: "sticky",
top: i * EDGE,
height: `calc(100vh - ${i * EDGE}px)`,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "7vh 8vw",
background: `linear-gradient(165deg, oklch(0.32 0.05 ${c.hue} / 0.92), oklch(0.19 0.02 270) 70%) oklch(0.19 0.02 270)`,
border: "1px solid oklch(0.34 0.02 270)",
borderRadius: "22px 22px 0 0",
boxShadow: "0 -18px 50px -22px rgb(0 0 0 / 0.65)",
willChange: "transform",
transformOrigin: "50% 0%",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.85rem", opacity: 0.6 }}>{c.n}</span>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.7rem", opacity: 0.35 }}>
{String(i + 1)} / {CARDS.length}
</span>
</div>
<h3 style={{ margin: 0, fontSize: "clamp(1.8rem, 1rem + 3.4vw, 3rem)", fontWeight: 750, letterSpacing: "-0.03em" }}>
{c.title}
</h3>
{/* вуаль: мягко «отводит» лист в тень вместо грязного filter:brightness */}
<div
className="ss-veil"
aria-hidden
style={{
position: "absolute",
inset: 0,
borderRadius: "inherit",
background: "oklch(0.11 0.015 270)",
opacity: 0,
pointerEvents: "none",
}}
/>
</section>
))}
</div>
<section style={{ height: "30vh", 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";
/**
* Sticky Stack — vanilla JS/GSAP+Lenis (без React). Карточки-секции липнут к верху и
* наезжают друг на друга; уходящая сжимается и темнеет.
*
* <div id="stack">
* <section data-ss-card style="position:sticky;top:0;height:100vh">…</section>
* <section data-ss-card style="position:sticky;top:0;height:100vh">…</section>
* </div>
* <script type="module">
* import { initStickyStack } from './sticky-stack.vanilla.js';
* const destroy = initStickyStack(document.querySelector('#stack'));
* </script>
*
* @param {HTMLElement} root — ОБЩИЙ контейнер sticky-карточек [data-ss-card] (важно: без
* индивидуальных обёрток, иначе липнуть некуда)
* @returns {() => void} destroy
*/
export function initStickyStack(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(() => {
const cards = [...root.querySelectorAll("[data-ss-card]")];
cards.forEach((card, i) => {
const next = cards[i + 1];
if (!next) return;
card.style.transformOrigin = "50% 0%";
// вуаль вместо filter:brightness (тот грязно темнит контент и дорог в отрисовке)
const veil = document.createElement("div");
veil.setAttribute("aria-hidden", "true");
veil.style.cssText = `position:absolute;inset:0;border-radius:inherit;
background:oklch(0.11 0.015 270);opacity:0;pointer-events:none;`;
card.appendChild(veil);
const st = { trigger: next, start: "top bottom", end: "top top", scrub: true };
// деликатный scale + вуаль с ease-кривой: почти незаметно в начале, добирает к наезду
gsap.to(card, { scale: 0.965, ease: "power1.in", scrollTrigger: { ...st } });
gsap.to(veil, { opacity: 0.45, ease: "power2.in", scrollTrigger: { ...st } });
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0