Discovery
Audit
User Flow
Site Map
Personas
Strategy
Wireframes
UI Kits
Prototypes
Visual Style
Interaction
Design QA
HTML/CSS/JS
CMS Build
GSAP Motion
Responsive
Optimization
Launch
Discovery
Audit
User Flow
Site Map
Personas
Strategy
Wireframes
UI Kits
Prototypes
Visual Style
Interaction
Design QA
HTML/CSS/JS
CMS Build
GSAP Motion
Responsive
Optimization
Launch
Add these to your <head> or before closing </body> tag. All from gsap.com:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.17/dist/lenis.min.js"></script>
The script uses data-scroll-anim on sections and
data-hero-card / data-flip-card on cards to
select and animate elements.
| Attribute | Value | Description |
|---|---|---|
data-scroll-anim |
hero-scatter |
Hero section — cards scatter on scroll |
data-scroll-anim |
services-pin |
Services section — pinned with header reveal |
data-scroll-anim |
cards-entrance |
Cards layer — entrance, spread, converge, flip |
data-hero-card |
1 / 2 / 3 |
Hero card index (left, center, right) |
data-flip-card |
1 / 2 / 3 |
Flip card index (Plan, Design, Develop) |
:root {
--dark: #000;
--light: #f9f4eb;
--light2: #f0ece5;
--accent-1: #e5d9f6;
--accent-2: #ffd2f3;
--accent-3: #fcdca6;
}
section {
position: relative;
width: 100vw;
height: 100svh;
padding: 2rem;
overflow: hidden;
}
.hero-cards {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 35%;
display: flex;
justify-content: center;
gap: 1rem;
}
.hero-cards .card {
flex: 1;
aspect-ratio: 5/7;
padding: 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.services-header {
position: relative;
width: 100%;
text-align: center;
transform: translateY(400%);
will-change: transform;
}
.cards {
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100svh;
display: flex;
justify-content: center;
z-index: -1;
background-color: var(--light);
}
.cards .cards-container .card { opacity: 0; }
.cards [data-flip-card="1"] {
transform: translateX(100%) translateY(-100%) rotate(-5deg) scale(0.25);
}
.cards [data-flip-card="2"] {
transform: translateX(0%) translateY(-100%) rotate(0deg) scale(0.25);
}
.cards [data-flip-card="3"] {
transform: translateX(-100%) translateY(-100%) rotate(5deg) scale(0.25);
}
.flip-card-inner {
position: relative;
width: 100%; height: 100%;
transform-style: preserve-3d;
}
.flip-card-front,
.flip-card-back {
position: absolute;
width: 100%; height: 100%;
border-radius: 1rem;
backface-visibility: hidden;
}
.flip-card-back {
transform: rotateY(180deg);
}
@keyframes floating {
0% { transform: translate(-50%, -50%); }
50% { transform: translate(-50%, -55%); }
100% { transform: translate(-50%, -50%); }
}
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);
const smoothStep = (p) => p * p * (3 - 2 * p);
if (window.innerWidth > 1000) {
// ========================================
// 1. HERO SCATTER — [data-scroll-anim="hero-scatter"]
// ========================================
const heroSection = document.querySelector('[data-scroll-anim="hero-scatter"]');
if (heroSection) {
ScrollTrigger.create({
trigger: heroSection,
start: "top top",
end: "75% top",
scrub: 1,
onUpdate: (self) => {
const progress = self.progress;
gsap.set(heroSection.querySelector(".hero-cards"), {
opacity: gsap.utils.interpolate(1, 0.5, smoothStep(progress)),
});
heroSection.querySelectorAll("[data-hero-card]").forEach((card) => {
const index = parseInt(card.dataset.heroCard) - 1;
const delay = index * 0.9;
const cardProgress = gsap.utils.clamp(
0, 1,
(progress - delay * 0.1) / (1 - delay * 0.1)
);
const y = gsap.utils.interpolate("0%", "350%", smoothStep(cardProgress));
const scale = gsap.utils.interpolate(1, 0.75, smoothStep(cardProgress));
let x = "0%";
let rotation = 0;
if (index === 0) {
x = gsap.utils.interpolate("0%", "90%", smoothStep(cardProgress));
rotation = gsap.utils.interpolate(0, -15, smoothStep(cardProgress));
} else if (index === 2) {
x = gsap.utils.interpolate("0%", "-90%", smoothStep(cardProgress));
rotation = gsap.utils.interpolate(0, 15, smoothStep(cardProgress));
}
gsap.set(card, { y, x, rotation, scale });
});
},
});
}
// ========================================
// 2. SERVICES PIN — [data-scroll-anim="services-pin"]
// ========================================
const servicesSection = document.querySelector('[data-scroll-anim="services-pin"]');
if (servicesSection) {
ScrollTrigger.create({
trigger: servicesSection,
start: "top top",
end: `+=${window.innerHeight * 4}px`,
pin: servicesSection,
pinSpacing: true,
});
// Cards layer: fixed → absolute positioning
const cardsLayer = document.querySelector('[data-scroll-anim="cards-entrance"]');
ScrollTrigger.create({
trigger: servicesSection,
start: "top top",
end: `+=${window.innerHeight * 4}px`,
onLeave: () => {
const rect = servicesSection.getBoundingClientRect();
gsap.set(cardsLayer, {
position: "absolute",
top: window.pageYOffset + rect.top,
left: 0, width: "100vw", height: "100vh",
});
},
onEnterBack: () => {
gsap.set(cardsLayer, {
position: "fixed",
top: 0, left: 0, width: "100vw", height: "100vh",
});
},
});
// ========================================
// 3. CARDS ENTRANCE — [data-scroll-anim="cards-entrance"]
// ========================================
ScrollTrigger.create({
trigger: servicesSection,
start: "top bottom",
end: `+=${window.innerHeight * 4}`,
scrub: 1,
onUpdate: (self) => {
const progress = self.progress;
// Services header slide-up
const headerProgress = gsap.utils.clamp(0, 1, progress / 0.9);
gsap.set(servicesSection.querySelector(".services-header"), {
y: gsap.utils.interpolate("400%", "0%", smoothStep(headerProgress)),
});
// Each flip card: enter → spread → converge → flip
document.querySelectorAll("[data-flip-card]").forEach((card) => {
const index = parseInt(card.dataset.flipCard) - 1;
const delay = index * 0.5;
const cardProgress = gsap.utils.clamp(
0, 1,
(progress - delay * 0.1) / (0.9 - delay * 0.1)
);
const innerCard = card.querySelector(".flip-card-inner");
// --- Y position (3 phases) ---
let y;
if (cardProgress < 0.4) {
y = gsap.utils.interpolate("-100%", "50%", smoothStep(cardProgress / 0.4));
} else if (cardProgress < 0.6) {
y = gsap.utils.interpolate("50%", "0%", smoothStep((cardProgress - 0.4) / 0.2));
} else {
y = "0%";
}
// --- Scale (3 phases) ---
let scale;
if (cardProgress < 0.4) {
scale = gsap.utils.interpolate(0.25, 0.75, smoothStep(cardProgress / 0.4));
} else if (cardProgress < 0.6) {
scale = gsap.utils.interpolate(0.75, 1, smoothStep((cardProgress - 0.4) / 0.2));
} else {
scale = 1;
}
// --- Opacity ---
let opacity;
if (cardProgress < 0.2) {
opacity = smoothStep(cardProgress / 0.2);
} else {
opacity = 1;
}
// --- X, rotate, flip (2 phases) ---
let x, rotate, rotationY;
if (cardProgress < 0.6) {
x = index === 0 ? "100%" : index === 1 ? "0%" : "-100%";
rotate = index === 0 ? -5 : index === 1 ? 0 : 5;
rotationY = 0;
} else if (cardProgress < 1) {
const np = smoothStep((cardProgress - 0.6) / 0.4);
x = gsap.utils.interpolate(
index === 0 ? "100%" : index === 1 ? "0%" : "-100%",
"0%", np
);
rotate = gsap.utils.interpolate(
index === 0 ? -5 : index === 1 ? 0 : 5,
0, np
);
rotationY = np * 180;
} else {
x = "0%"; rotate = 0; rotationY = 180;
}
gsap.set(card, { opacity, y, x, rotate, scale });
gsap.set(innerCard, { rotationY });
});
},
});
}
}
});
<!-- Hero section — cards scatter on scroll -->
<section class="hero" data-scroll-anim="hero-scatter">
<div class="hero-cards">
<div class="card" data-hero-card="1">...</div>
<div class="card" data-hero-card="2">...</div>
<div class="card" data-hero-card="3">...</div>
</div>
</section>
<section class="about">...</section>
<!-- Services — pinned, header reveals -->
<section class="services" data-scroll-anim="services-pin">
<div class="services-header">
<h1>Stuff I make so you don't have to</h1>
</div>
<!-- mobile-cards fallback here -->
</section>
<!-- Fixed cards layer — enter, spread, converge, flip -->
<section class="cards" data-scroll-anim="cards-entrance">
<div class="cards-container">
<div class="card" data-flip-card="1">
<div class="card-wrapper">
<div class="flip-card-inner">
<div class="flip-card-front">...</div>
<div class="flip-card-back">...</div>
</div>
</div>
</div>
<div class="card" data-flip-card="2">...</div>
<div class="card" data-flip-card="3">...</div>
</div>
</section>
<section class="outro">...</section>
| Phase | Progress | What happens |
|---|---|---|
| Hero Scatter | 0 → 75% | Cards move up 350%, scale to 0.75, side cards spread ±90% with ±15° rotation, container fades to 0.5 |
| Card Enter | 0% → 40% | Cards fly in from top (y -100%→50%), scale up (0.25→0.75), fade in |
| Card Settle | 40% → 60% | Cards settle to center (y 50%→0%), scale to full (0.75→1) |
| Card Flip | 60% → 100% | Cards converge from spread (±100%) to center, rotate Y 0→180° to reveal back face |
1. Add the three CDN <script> tags to your project's <head> code.
2. Paste all CSS into your project's global styles.
3. Build the HTML structure using Webstudio's element tree.
4. Add custom attributes on each element:
— Section: data-scroll-anim = "hero-scatter"
— Hero cards: data-hero-card = "1", "2", "3"
— Services: data-scroll-anim = "services-pin"
— Cards layer: data-scroll-anim = "cards-entrance"
— Flip cards: data-flip-card = "1", "2", "3"
5. Paste the JavaScript into an embed <script> block before </body>.
6. Publish and scroll!