SectionsAdvancedApril 13, 2026

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 →
Kickandbass
Westend
Delivrd
Socialstats
Case Study

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.

Case Study

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.

Case Study

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.

Case Study

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
Case Study

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
Case Study

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
Case Study

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
Case Study

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.

demo.jsx
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>
  );
}
index.jsx
"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>
  );
}
demo.module.css
.page {
  background: #f1ebe7;
  color: #111;
}

.spacer {
  height: 50vh;
}
styles.module.css
.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;
  }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.