Quiet Control
Fluid Structures
Wired Thought
Silent Repetition
Add these to your <head> or before closing </body> tag:
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://unpkg.com/lenis@1.1.18/dist/lenis.min.js"></script>
[data-sticky-cards] {
position: relative;
width: 100%;
height: 100svh;
overflow: hidden;
background-color: #e3e3db;
perspective: 1000px;
}
[data-sticky-cards] .sticky-card {
position: absolute;
top: 50%;
left: 50%;
width: 65%;
height: 60%;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 2.5rem;
border-radius: 1rem;
color: #fff;
transform-origin: center bottom;
will-change: transform;
}
.sticky-card .col {
flex: 1;
height: 100%;
}
.sticky-card .col:nth-child(1) {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0.5rem;
}
.sticky-card .col:nth-child(2) {
border-radius: 0.75rem;
overflow: hidden;
}
/* Responsive */
@media (max-width: 1000px) {
[data-sticky-cards] .sticky-card {
width: calc(100% - 4rem);
height: 75%;
flex-direction: column;
}
.sticky-card .col {
width: 100%;
}
}
gsap.registerPlugin(ScrollTrigger);
document.addEventListener("DOMContentLoaded", () => {
// Lenis smooth scroll
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
// Initialize each [data-sticky-cards] section
document.querySelectorAll("[data-sticky-cards]").forEach((section) => {
const cards = section.querySelectorAll(".sticky-card");
const totalCards = cards.length;
const segmentSize = 1 / totalCards;
// Read config from data attributes
const yOffset = parseFloat(section.dataset.cardsYOffset) || 5;
const scaleStep = parseFloat(section.dataset.cardsScaleStep) || 0.075;
const scrollLength = parseFloat(section.dataset.cardsScrollLength) || 8;
const bg = section.dataset.cardsBg;
if (bg) section.style.backgroundColor = bg;
// Apply per-card colors and z-index from data attributes
cards.forEach((card, i) => {
const color = card.dataset.cardColor;
const z = card.dataset.cardZ;
if (color) card.style.backgroundColor = color;
if (z) card.style.zIndex = z;
gsap.set(card, {
xPercent: -50,
yPercent: -50 + i * yOffset,
scale: 1 - i * scaleStep,
});
});
// Scroll-driven animation
ScrollTrigger.create({
trigger: section,
start: "top top",
end: `+=${window.innerHeight * scrollLength}px`,
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate: (self) => {
const progress = self.progress;
const activeIndex = Math.min(
Math.floor(progress / segmentSize),
totalCards - 1
);
const segProgress =
(progress - activeIndex * segmentSize) / segmentSize;
cards.forEach((card, i) => {
if (i < activeIndex) {
gsap.set(card, { yPercent: -250, rotationX: 35 });
} else if (i === activeIndex) {
gsap.set(card, {
yPercent: gsap.utils.interpolate(-50, -200, segProgress),
rotationX: gsap.utils.interpolate(0, 35, segProgress),
scale: 1,
});
} else {
const behindIndex = i - activeIndex;
gsap.set(card, {
yPercent: -50 + (behindIndex - segProgress) * yOffset,
rotationX: 0,
scale: 1 - (behindIndex - segProgress) * scaleStep,
});
}
});
},
});
});
});
<!-- Sticky cards container (with defaults) -->
<section data-sticky-cards>
<div class="sticky-card" data-card-color="#3d2fa9" data-card-z="5">
<div class="col">
<p>Subtitle</p>
<h1>Title</h1>
</div>
<div class="col">
<img src="image.jpg" alt="" />
</div>
</div>
<!-- Add more .sticky-card divs... -->
</section>
<!-- Custom configuration -->
<section
data-sticky-cards
data-cards-bg="#1a1a1a"
data-cards-y-offset="8"
data-cards-scale-step="0.05"
data-cards-scroll-length="10"
>
<div class="sticky-card" data-card-color="#e74c3c" data-card-z="3">
...
</div>
<div class="sticky-card" data-card-color="#2ecc71" data-card-z="2">
...
</div>
</section>
Container attributes (on the data-sticky-cards section):
| Attribute | Default | Description |
|---|---|---|
data-sticky-cards |
required | Enables the sticky card animation on this section |
data-cards-bg |
#e3e3db |
Background color of the pinned section |
data-cards-y-offset |
5 |
Vertical stacking offset between cards (yPercent units) |
data-cards-scale-step |
0.075 |
Scale reduction per card in the stack (0-1) |
data-cards-scroll-length |
8 |
Scroll distance multiplier (viewport heights) |
Per-card attributes (on each .sticky-card):
| Attribute | Default | Description |
|---|---|---|
data-card-color |
none | Background color for this card |
data-card-z |
none | z-index stacking order (higher = on top) |
The animation uses GSAP ScrollTrigger to pin the section and scrub through a scroll-driven card stack. Each card starts centered and slightly offset/scaled behind the previous one. As the user scrolls:
1. The active card tilts upward with rotationX and
slides out of view via yPercent.
2. Cards behind the active card smoothly shift forward, scaling up
to fill the gap.
3. Cards already dismissed stay offscreen above.
Lenis provides smooth inertial scrolling for a polished feel. The entire animation is configured via data attributes — no JS changes needed to customize behavior.