Sticky Cards Parallax
Cards stack as you scroll — each sticks to the top while the next one slides underneath. The image inside each card zooms from 2× to 1× as the card settles, and previous cards scale down slightly as new ones arrive.
View Full Demo →Preview
Kickandbass
A motion-forward headless Shopify storefront built for a London-based music label. Every interaction is driven by an animation language developed from the brand's visual identity.
See more
Westend
A nonprofit campaign site for a community arts organisation. The brief was to make the site feel alive — every scroll, hover, and transition reinforces the brand's energy.
See more
Delivrd
Brand identity and web for a logistics startup entering a crowded market. The challenge was to make 'fast and reliable' feel premium rather than generic.
See more
Socialstats
A data product for social media managers. Dense information, zero visual noise. The design system was built to make complex analytics feel intuitive at a glance.
See more
Kevin Davis
An artist portfolio for a London-based photographer and director. The goal was to let the work speak — minimal UI, maximum presence.
See more
Source
"use client";
import { useRef } from "react";
import Image from "next/image";
import { motion, useScroll, useTransform } from "framer-motion";
import styles from "./styles.module.css";
// Internal Card — each card tracks its own entry progress for image zoom
function Card({ i, title, description, image, color, progress, range, targetScale }) {
const cardRef = useRef(null);
// Per-card: image scales from 2→1 as the card scrolls into its sticky position
const { scrollYProgress: entryProgress } = useScroll({
target: cardRef,
offset: ["start end", "start start"],
});
const imageScale = useTransform(entryProgress, [0, 1], [2, 1]);
// Global: card scales down as subsequent cards stack on top
const cardScale = useTransform(progress, range, [1, targetScale]);
return (
<div ref={cardRef} className={styles.cardContainer}>
<motion.div
className={styles.card}
style={{
backgroundColor: color,
scale: cardScale,
top: `calc(-5vh + ${i * 25}px)`,
}}
>
<h2 className={styles.title}>{title}</h2>
<div className={styles.body}>
<div className={styles.description}>
<p>{description}</p>
<a href="#" className={styles.link}>
See more{" "}
<svg width="16" height="10" viewBox="0 0 22 12" fill="none">
<path
d="M21.5303 6.53033C21.8232 6.23744 21.8232 5.76256 21.5303 5.46967L16.7574 0.696699C16.4645 0.403806 15.9896 0.403806 15.6967 0.696699C15.4038 0.989592 15.4038 1.46447 15.6967 1.75736L19.9393 6L15.6967 10.2426C15.4038 10.5355 15.4038 11.0104 15.6967 11.3033C15.9896 11.5962 16.4645 11.5962 16.7574 11.3033L21.5303 6.53033ZM0 6.75L21 6.75V5.25L0 5.25L0 6.75Z"
fill="currentColor"
/>
</svg>
</a>
</div>
<div className={styles.imageContainer}>
<motion.div className={styles.imageInner} style={{ scale: imageScale }}>
<Image src={image} alt={title} fill className={styles.img} sizes="40vw" />
</motion.div>
</div>
</div>
</motion.div>
</div>
);
}
export default function StickyCardsParallax({ cards = [] }) {
const containerRef = useRef(null);
// Track overall scroll to drive the card scale-down stacking effect
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});
return (
<main ref={containerRef} className={styles.main}>
{cards.map((card, i) => {
const targetScale = 1 - (cards.length - i) * 0.05;
return (
<Card
key={i}
i={i}
{...card}
progress={scrollYProgress}
range={[i / cards.length, 1]}
targetScale={targetScale}
/>
);
})}
</main>
);
}
.main {
margin: 0;
padding: 0;
}
/* ── One card per viewport height of scroll space ── */
.cardContainer {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
position: sticky;
/* top is set via Framer Motion style prop: calc(-5vh + i * 25px) */
width: min(90%, 1200px);
height: 85vh;
border-radius: 1.25rem;
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
transform-origin: top center;
overflow: hidden;
}
.title {
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 700;
letter-spacing: -0.04em;
color: #f5f5f5;
margin: 0;
}
.body {
display: flex;
gap: 2rem;
flex: 1;
min-height: 0;
}
.description {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
color: rgba(255, 255, 255, 0.65);
min-width: 0;
}
.description p {
margin: 0;
font-size: clamp(0.875rem, 1.25vw, 1rem);
line-height: 1.65;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #f5f5f5;
text-decoration: none;
font-size: 0.875rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding-bottom: 0.25rem;
align-self: flex-start;
transition: border-color 0.2s;
}
.link:hover {
border-color: rgba(255, 255, 255, 0.8);
}
.imageContainer {
flex: 1.5;
position: relative;
border-radius: 0.75rem;
overflow: hidden;
min-height: 0;
}
.imageInner {
position: absolute;
inset: 0;
/* Framer Motion applies scale here; oversize handled by container overflow:hidden */
}
.img {
object-fit: cover;
}
@media (max-width: 768px) {
.body {
flex-direction: column;
}
.imageContainer {
flex: none;
height: 40vw;
}
}
Dependencies
framer-motion