FollowArt Scroll Animation
Goal
Build a vertical scroll page of six stacked full-screen sections (plus a footer). The signature effect: each section's inner content block starts tilted at 30 degrees (pivoting from its bottom-left corner) and, as you scroll it into view, scrubs back to level (0deg). At the same time each section pins at the bottom of the viewport so the next section slides up and overlaps it — producing a layered "card-deck" reveal where straightened panels stack underneath the incoming tilted one. Smooth scroll via Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework — plain Vite-style module imports:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Layout / HTML
<main> contains six <section> elements followed by a <footer>. Every section wraps its content in a single .container. The class order matters — sections are .one … .six and each has a distinct background color.
Per-section internal structure (classes the CSS/JS rely on: section, .container, .col, .img, h1, p):
.one—.container> two.col. col1:<h1>(short title). col2:<p>(paragraph, aligned to bottom)..two—.container> two.col. col1:.img><img>(centered). col2:<h1>at top +<p>at bottom (space-between column)..three—.container> two.col. col1:<h1>at top +<p>at bottom (space-between column). col2:.img><img>(centered)..four—.container(no.col, it is a centered column) containing.img><img>, then<h1>, then two<p>blocks. This is the tallest section (200svh)..five—.container> two.col. col1:<h1>. col2:<p>(aligned to bottom)..six—.container> two.col. col1:<h1>. col2:<p>(aligned to bottom).<footer>— a single centered<h1>(e.g. "Footer"). The footer is NOT a<section>and is not animated.
Use neutral editorial placeholder copy. Suggested headings in order: "Entry Point", "Gesture", "Variation", "The Stance", "Stillness", "Release". Paragraphs are 1–3 sentences of abstract art-direction prose.
Styling
Import fonts:
@import url("https://fonts.cdnfonts.com/css/newsflash-bb");
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
Palette (CSS variables, one solid background per section container, with matching text color):
--base-100: #8e9487→.onebg, text#000--base-200: #1e1e1c→.twobg, text#fff--base-300: #f681ff→.threebg, text#000--base-400: #62f008→.fourbg, text#000--base-500: #bca147→.fivebg, text#fff--base-600: #c2c1c2→.sixbg, text#000- footer bg
#0f0f0f, text#fff
Typography:
h1: font-family"Newsflash BB", sans-serif(a bold, uppercase comic/display face),text-transform: uppercase,font-size: clamp(3rem, 10vw, 15rem),font-weight: 500,letter-spacing: -0.025rem,line-height: 1.p: font-family"DM Sans", sans-serif,font-size: 1.75rem,font-weight: 400,letter-spacing: -0.025rem,line-height: 1.25.
Global / reset:
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }- Hide the scrollbar:
::-webkit-scrollbar { display:none; }
Sections & container (this is what the effect hangs on):
section { position:relative; width:100%; height:100svh; min-height:100svh; overflow:hidden; }section.four { height:200svh; }(extra scroll length for the tallest panel)..container { position:relative; width:100%; height:100%; padding:2rem; display:flex; transform: rotate(30deg); transform-origin: bottom left; will-change: transform; }— the initial 30deg tilt lives in CSS; GSAP animates it to 0. Thetransform-origin: bottom leftis essential: the panel swings from its bottom-left corner..container .col { flex:1; display:flex; }.container .col .img { width:35%; height:auto; aspect-ratio:4/5; overflow:hidden; }- Column alignment rules:
.one/.five/.six .col:nth-child(2)→align-items:flex-end(paragraph sits at bottom)..two .col:nth-child(1),.three .col:nth-child(2)→justify-content:center; align-items:center(image centered)..two .col:nth-child(2),.three .col:nth-child(1)→flex-direction:column; justify-content:space-between(title top, paragraph bottom)..four .container { flex-direction:column; justify-content:center; align-items:center; text-align:center; gap:1rem; }.four .img { width:30%; margin-bottom:4rem; }(this.imgis a direct child of the container, so it keeps its natural aspect ratio — no 4/5 clamp here)..four p { width:50%; }footer { position:relative; width:100%; height:70svh; padding:2rem; display:flex; justify-content:center; align-items:center; }
GSAP effect (the important part — be exact)
Smooth scroll wiring (Lenis + GSAP ticker)
Inside DOMContentLoaded:
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Lenis is driven by GSAP's ticker (not its own rAF), Lenis scroll events call ScrollTrigger.update, and lag smoothing is disabled so scrub stays glued to scroll position.
Register the plugin once: gsap.registerPlugin(ScrollTrigger);
Per-section loop
const sections = document.querySelectorAll("section"); (six of them — the footer is excluded). Then sections.forEach((section, index) => { ... }). Inside, grab const container = section.querySelector(".container"); and set up TWO things:
1) Un-tilt tween (applies to every section, including the last):
gsap.to(container, {
rotation: 0,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top bottom", // section's top hits the viewport bottom
end: "top 20%", // section's top reaches 20% down from the viewport top
scrub: true,
},
});
- Animated property:
rotationfrom 30deg (the CSS start value GSAP reads off the element) → 0deg. ease: "none"+scrub: true→ strictly linear, driven by scroll position.- Effect: as the panel rises from the bottom of the screen toward the top, it straightens from a 30° tilt (pivoting bottom-left) to perfectly level; it is fully level by the time its top reaches 20% from the top.
2) Pin / stack (every section EXCEPT the last):
if (index === sections.length - 1) return; // skip pin for the 6th section
ScrollTrigger.create({
trigger: section,
start: "bottom bottom", // section's bottom reaches the viewport bottom (fully in view)
end: "bottom top", // section's bottom reaches the viewport top
pin: true,
pinSpacing: false, // CRITICAL — no spacer, so the next section scrolls up OVER this one
});
pinSpacing: falseis what creates the overlap/stacking: the pinned section holds in place while the following section slides up and covers it, layering the deck.- The pin holds from the moment the section is fully in view until its bottom scrolls off the top.
No SplitText, no CustomEase, no manual lerp/rAF interpolation — the only easing is linear scrub. The whole effect is: CSS gives the 30deg start tilt → ScrollTrigger scrubs rotation to 0 on entry → a second pinned ScrollTrigger with pinSpacing:false stacks the panels.
Assets / images
Three images total, all portrait-oriented stylized character portraits in a screen-print / pop-art comic aesthetic: a single woman rendered with cool blue-toned skin and heavy black hair, drawn in flat inked cel-shading with a subtle grainy paper texture, each set against a saturated flat solid-color background. Framing is head-and-shoulders (bust). Portrait aspect ~4:5. Use whatever imagery fits this look; roles and treatment:
- Section two image — displayed inside a
.col, so it renders atwidth:35%,aspect-ratio: 4/5,object-fit: cover, cropped/overflow hidden. Subject: woman with dark shoulder-length hair, gold hoop earring and a cream/off-white blazer, on a bold cobalt/royal-blue background. Dominant colors: royal blue + cream, with a gold accent. - Section three image — same treatment as section two (35% width, 4/5 portrait, cover, centered in its column). Subject: woman in yellow-framed sunglasses and a yellow-and-blue horizontally striped sweater, on a vivid red background. Dominant colors: red + yellow + blue.
- Section four image — centered near the top of the tall panel at
width:30%, keeping its natural aspect ratio (no 4/5 clamp). Subject: woman with a dark chin-length bob and blunt bangs wearing a cream-and-blue striped Breton top, on a light sky-blue background. Dominant colors: sky blue + cream.
Describe generically: three vivid, high-contrast pop-art bust portraits of a blue-skinned woman, each on a different saturated flat background, portrait framing ~4:5.
Behavior notes
- Desktop-first layout. At
max-width: 1000px:pfont-size drops to1.25rem;.containerswitches toflex-direction: column;.two .col:nth-child(2)and.three .col:nth-child(1)becomejustify-content:center; gap:1rem;section.fourheight becomes125svh;.four pbecomeswidth:100%. The rotation + pin effect still runs on all viewport sizes. - Heights use
svhunits so mobile browser chrome doesn't break the full-screen sections. - The footer stays static (never tilts, never pins).
- No reduced-motion handling in the original; the animation is purely scroll-scrubbed (nothing autoplays).