SectionsIntermediateMay 11, 2026
Dual Push Cards
Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.
View Full Demo →Preview
Source
demo.jsx
import DualPushCards from "./index.jsx";
import { dualPushCards } from "../demo-data.js";
export default function DualPushCardsDemo() {
return (
<div style={{ paddingTop: "50vh", paddingBottom: "50vh" }}>
<DualPushCards {...dualPushCards} />
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger);
export default function DualPushCards({ cards = [] }) {
const gridRef = useRef(null);
useEffect(() => {
const el = gridRef.current;
if (!el) return;
const cardEls = el.querySelectorAll("[data-dual-card]");
const triggers = [];
cardEls.forEach((card) => {
const st = ScrollTrigger.create({
trigger: card,
start: "top bottom",
end: "bottom top",
scrub: true,
onUpdate(self) {
card.style.setProperty("--progress", self.progress);
},
});
triggers.push(st);
});
return () => triggers.forEach((st) => st.kill());
}, []);
return (
<div ref={gridRef} className={styles.grid}>
{cards.map((card, i) => (
<a
key={i}
className={styles.card}
href={card.href || "#"}
data-dual-card
>
<h2 className={styles.heading}>{card.heading}</h2>
<div className={styles.imageBg} aria-hidden="true">
<div className={styles.imageInner}>
<img
className={styles.image}
src={card.image}
alt={card.heading}
loading="lazy"
/>
</div>
</div>
<div className={styles.overlay} />
<span className={styles.button} aria-hidden="true" tabIndex={-1}>
<span className={styles.buttonArrow}>→</span>
<span>{card.buttonLabel}</span>
</span>
</a>
))}
</div>
);
}
styles.module.css
/* ── Grid wrapper ── */
.grid {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 4px;
padding-right: 4px;
}
@media (min-width: 1024px) {
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Card ── */
.card {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
border-radius: 16px;
padding: 20px;
aspect-ratio: 1;
color: #ffffff;
text-decoration: none;
}
@media (min-width: 768px) {
.card {
aspect-ratio: 1.3;
padding: 24px;
}
}
@media (min-width: 1024px) {
.card {
aspect-ratio: 1.5;
padding: 28px;
}
}
/* ── Heading ── */
.heading {
position: relative;
z-index: 2;
font-size: clamp(1.5rem, 0.7826rem + 3.587vw, 3.5625rem);
font-weight: 500;
line-height: 1.1;
max-width: 280px;
}
@media (min-width: 1024px) {
.heading {
font-size: clamp(1.25rem, 0.9674rem + 1.413vw, 2.0625rem);
max-width: 280px;
}
}
/* ── Background image layer ── */
.imageBg {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
}
.imageInner {
position: absolute;
inset: 0;
/* Parallax driven by --progress */
transform: scale(calc(1.15 - var(--progress, 0) * 0.15))
translateY(calc((var(--progress, 0) * 2 - 1) * 10%));
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Dark overlay ── */
.overlay {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.2) 100%
);
}
/* ── Blur button ── */
.button {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 8px 16px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
font-size: clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem);
font-weight: 500;
color: #ffffff;
pointer-events: none;
transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1);
}
.card:hover .button {
transform: scale(1.05);
}
.buttonArrow {
display: inline-flex;
font-size: 1em;
line-height: 1;
}
Dependencies
gsap

