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

Parallax Panels

Панели-сервисы с разнослойным параллаксом: визуал едет внутри clip, крупный контурный номер — навстречу скроллу.

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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/parallax-panels.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 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-services)v1.0.0