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

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

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