Sticky About
A scroll-pinned two-column section with a stacked image gallery on the left and matching text on the right. Scroll snaps between items with clip-path reveal transitions and a progress bar. Collapses to full-screen stacked cards on mobile.
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.
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.
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.
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.

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.

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.

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.

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.
Source
import StickyAbout from "./index.jsx";
import { stickyAbout } from "../demo-data.js";
import styles from "./demo.module.css";
export default function StickyAboutDemo() {
return (
<div className={styles.page}>
<div className={styles.spacer} />
<StickyAbout items={stickyAbout.items} />
<div className={styles.spacer} />
</div>
);
}
"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 StickyAbout({ items = [] }) {
const wrapperRef = useRef(null);
const visualRefs = useRef([]);
const textRefs = useRef([]);
const mobileCardRefs = useRef([]);
const progressBarRef = useRef(null);
useEffect(() => {
const wrapper = wrapperRef.current;
const total = items.length;
if (!wrapper || total === 0) return;
const scrollDuration = window.innerHeight * (total - 1);
const mm = gsap.matchMedia();
/* ---- Desktop: two-column pinned layout ---- */
mm.add("(min-width: 769px)", () => {
const visuals = visualRefs.current;
const texts = textRefs.current;
gsap.set(visuals[0], { visibility: "visible", clipPath: "inset(0% round 0.75em)" });
gsap.set(texts[0], { visibility: "visible", opacity: 1 });
ScrollTrigger.create({
trigger: wrapper,
start: "top top",
end: `+=${scrollDuration}`,
scrub: true,
pin: `.${styles.scroll}`,
anticipatePin: 1,
snap: {
snapTo: (value) => {
const idx = Math.round(value * (total - 1));
return idx / (total - 1);
},
duration: 0.3,
ease: "power1.inOut",
},
onUpdate: (self) => {
const idx = Math.round(self.progress * (total - 1));
visuals.forEach((el, i) => {
if (!el) return;
gsap.set(el, {
visibility: i === idx ? "visible" : "hidden",
clipPath: i === idx ? "inset(0% round 0.75em)" : "inset(50% round 0.75em)",
});
});
texts.forEach((el, i) => {
if (!el) return;
gsap.set(el, {
visibility: i === idx ? "visible" : "hidden",
opacity: i === idx ? 1 : 0,
});
});
const progress = idx / (total - 1);
if (progressBarRef.current) {
progressBarRef.current.style.transform = `scale3d(${progress}, 1, 1)`;
}
},
});
});
/* ---- Mobile: full-screen stacked cards ---- */
mm.add("(max-width: 768px)", () => {
const cards = mobileCardRefs.current;
gsap.set(cards[0], { visibility: "visible", opacity: 1, clipPath: "inset(0% round 0.75em)" });
ScrollTrigger.create({
trigger: wrapper,
start: "top top",
end: `+=${scrollDuration}`,
scrub: true,
pin: true,
anticipatePin: 1,
snap: {
snapTo: (value) => {
const idx = Math.round(value * (total - 1));
return idx / (total - 1);
},
duration: 0.3,
ease: "power1.inOut",
},
onUpdate: (self) => {
const idx = Math.round(self.progress * (total - 1));
cards.forEach((card, i) => {
if (!card) return;
gsap.set(card, {
visibility: i === idx ? "visible" : "hidden",
opacity: i === idx ? 1 : 0,
clipPath: i === idx ? "inset(0% round 0.75em)" : "inset(50% round 0.75em)",
});
});
},
});
});
return () => mm.revert();
}, [items]);
return (
<section ref={wrapperRef} className={styles.wrapper}>
{/* ---- Desktop layout ---- */}
<div className={styles.scroll}>
<div className={styles.container}>
{/* Left — image stack */}
<div className={styles.col}>
<div className={styles.imgList}>
{items.map((item, i) => (
<div
key={i}
className={styles.imgItem}
ref={(el) => (visualRefs.current[i] = el)}
>
<img src={item.image} alt={item.title} className={styles.img} />
</div>
))}
<div className={styles.progressWrapper}>
<div ref={progressBarRef} className={styles.progressBar} />
</div>
</div>
</div>
{/* Right — text stack */}
<div className={styles.col}>
<div className={styles.textList}>
{items.map((item, i) => (
<div
key={i}
className={styles.textItem}
ref={(el) => (textRefs.current[i] = el)}
>
{item.label && <span className={styles.tag}>{item.label}</span>}
<h2 className={styles.heading}>{item.title}</h2>
{item.content?.map((paragraph, j) => (
<p key={j} className={styles.paragraph}>{paragraph}</p>
))}
</div>
))}
</div>
</div>
</div>
</div>
{/* ---- Mobile layout ---- */}
<div className={styles.mobileWrapper}>
{items.map((item, i) => (
<div
key={i}
className={styles.mobileCard}
ref={(el) => (mobileCardRefs.current[i] = el)}
>
<div className={styles.mobileImgWrapper}>
<img src={item.image} alt={item.title} className={styles.img} />
</div>
<div className={styles.mobileText}>
{item.label && <span className={styles.tag}>{item.label}</span>}
<h2 className={styles.heading}>{item.title}</h2>
{item.content?.map((paragraph, j) => (
<p key={j} className={styles.paragraph}>{paragraph}</p>
))}
</div>
</div>
))}
</div>
</section>
);
}
.page {
background: #f1ebe7;
color: #111;
}
.spacer {
height: 50vh;
}
.wrapper {
width: 100%;
padding: 0 1.25em;
position: relative;
min-height: 100vh;
}
/* ==================== DESKTOP ==================== */
.scroll {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
display: flex;
width: 100%;
max-width: 70em;
gap: 1.25em;
margin: 0 auto;
}
.col {
flex: 1;
position: relative;
}
/* ---- Image stack ---- */
.imgList {
width: 100%;
aspect-ratio: 1 / 1.3;
position: relative;
}
.imgItem {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
visibility: hidden;
clip-path: inset(50% round 0.75em);
transition: clip-path 0.4s ease;
}
.img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ---- Progress bar ---- */
.progressWrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 99px;
overflow: hidden;
z-index: 2;
}
.progressBar {
width: 100%;
height: 100%;
background-color: #fff;
transform: scale3d(0, 1, 1);
transform-origin: 0% 50%;
transition: transform 0.3s ease-out;
}
/* ---- Text stack ---- */
.textList {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
.textItem {
position: absolute;
right: 0;
width: 100%;
max-width: 27.5em;
margin: 0 auto;
text-align: center;
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease;
}
/* ==================== TYPOGRAPHY ==================== */
.tag {
font-size: 0.6rem;
color: #000;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 0.5rem;
display: block;
}
.heading {
font-size: 3.5rem;
font-weight: 200;
line-height: 1.15;
letter-spacing: -0.015rem;
margin: 1rem 0 0.75rem;
color: #000;
}
.paragraph {
font-size: 1rem;
line-height: 1.5;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
color: #111;
max-width: 460px;
margin-left: auto;
margin-right: auto;
text-align: center;
}
/* ==================== MOBILE ==================== */
.mobileWrapper {
display: none;
}
.mobileCard {
position: absolute;
inset: 0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem 1.25rem;
flex-direction: column;
visibility: hidden;
opacity: 0;
clip-path: inset(50% round 0.75em);
transition: opacity 0.3s ease, clip-path 0.3s ease;
}
.mobileImgWrapper {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 0.75em;
overflow: hidden;
}
.mobileText {
text-align: center;
}
@media (max-width: 768px) {
.scroll {
display: none;
}
.mobileWrapper {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
}
.heading {
font-size: 1.5rem;
line-height: 1;
}
.tag {
font-size: 0.55rem;
margin: 1rem;
}
}
Dependencies
gsap