← каталог
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
Что нужно в проекте до установки
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";
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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Card Image Parallax — vanilla JS/GSAP+Lenis (без React). Обложки (крупнее контейнера,
* height ~130%) дрейфуют внутри clip при скролле; карточки входят stagger-ом снизу.
*
* <article data-cip-card>
* <div style="overflow:hidden;aspect-ratio:4/3">
* <img data-cip-art src="cover.jpg" style="height:130%;object-fit:cover">
* </div>
* </article>
* <script type="module">
* import { initCardImageParallax } from './card-image-parallax.vanilla.js';
* const destroy = initCardImageParallax(document.body); // или контейнер карточек
* </script>
*
* @param {HTMLElement} root — контейнер с [data-cip-card]; внутри [data-cip-art]
* @returns {() => void} destroy
*/
export function initCardImageParallax(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(() => {
gsap.utils.toArray("[data-cip-card]", root).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("[data-cip-art]");
if (inner) {
gsap.fromTo(
inner,
{ yPercent: -6 },
{ yPercent: 6, ease: "none", scrollTrigger: { trigger: card, start: "top bottom", end: "bottom top", scrub: true } },
);
}
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-work)v1.0.0