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

Card Image Parallax

Сетка work-карточек: обложка крупнее контейнера и движется внутри clip при скролле; вход карточек — stagger снизу.

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

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
cards?WorkCard[]CARDS
WorkCard — что внутри
  • title: string
  • tag: string
  • img: string— URL обложки (демо-снимки из каталога — замени на свои).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/card-image-parallax.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";

export interface WorkCard {
  title: string;
  tag: string;
  /** URL обложки (демо-снимки из каталога — замени на свои). */
  img: string;
}

const CARDS: WorkCard[] = [
  { title: "Neon Pulse", tag: "Brand / Web", img: "https://motiva.pages.dev/img/696-800x620.jpg" },
  { title: "Void Studio", tag: "Motion / 3D", img: "https://motiva.pages.dev/img/232-800x620.jpg" },
  { title: "Aurora Labs", tag: "Product / UI", img: "https://motiva.pages.dev/img/943-800x620.jpg" },
  { title: "Solar Field", tag: "Editorial", img: "https://motiva.pages.dev/img/353-800x620.jpg" },
];

/**
 * Сетка work-карточек: обложка крупнее контейнера (130%) и движется внутри clip при скролле —
 * параллакс внутри карточки; вход карточек — stagger снизу. Уважает reduced-motion / data-shot.
 */
export function CardImageParallax({ cards = CARDS }: { cards?: WorkCard[] }) {
  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(() => {
      gsap.utils.toArray<HTMLElement>(".cip-card").forEach((card, i) => {
        gsap.from(card, {
          y: 60,
          opacity: 0,
          duration: 1,
          ease: "power3.out",
          delay: (i % 2) * 0.15,
          scrollTrigger: { trigger: card, start: "top 88%" },
        });
        const inner = card.querySelector(".cip-art");
        if (inner) {
          gsap.fromTo(
            inner,
            { yPercent: -6 },
            {
              yPercent: 6,
              ease: "none",
              scrollTrigger: { trigger: card, start: "top bottom", end: "bottom top", scrub: true },
            },
          );
        }
      });
    }, el);

    return () => {
      ctx.revert();
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, [cards]);

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
      <style>{`
        .cip-card .cip-art { transition: scale 0.6s cubic-bezier(0.16,1,0.3,1); }
        .cip-card:hover .cip-art { scale: 1.04; }
      `}</style>
      <section style={{ height: "30vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
          крути ↓ — обложки движутся внутри карточек
        </span>
      </section>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(2, 1fr)",
          gap: "4vw",
          paddingInline: "6vw",
          paddingBottom: "18vh",
        }}
      >
        {cards.map((c, i) => (
          <article key={c.title} className="cip-card" style={{ marginTop: i % 2 ? "12vh" : 0 }}>
            <div
              style={{
                position: "relative",
                aspectRatio: "4 / 3",
                borderRadius: 16,
                overflow: "hidden",
                border: "1px solid oklch(0.3 0.02 270)",
              }}
            >
              <img
                className="cip-art"
                src={c.img}
                alt={c.title}
                loading="lazy"
                style={{
                  position: "absolute",
                  inset: "-15% 0",
                  width: "100%",
                  height: "130%",
                  objectFit: "cover",
                }}
              />
              <span
                style={{
                  position: "absolute",
                  right: 12,
                  bottom: 8,
                  fontFamily: "ui-monospace, monospace",
                  fontSize: "1.6rem",
                  fontWeight: 700,
                  opacity: 0.7,
                }}
              >
                {String(i + 1).padStart(2, "0")}
              </span>
            </div>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginTop: 10 }}>
              <h3 style={{ margin: 0, fontSize: "1.05rem", fontWeight: 650 }}>{c.title}</h3>
              <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.7rem", color: "oklch(0.7 0.02 265)" }}>
                {c.tag}
              </span>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-work)v1.0.0