AnimationsIntermediateApril 9, 2026

Horizontal Scroll

Full-viewport horizontal scroll section driven by GSAP ScrollTrigger. Panels are pinned and translated on the x-axis as you scroll vertically. Stacks vertically on mobile.

View Full Demo →

Editorial

Campaign

Portrait

Studio

Archive

demo.jsx
import HorizontalScroll from './index.jsx';
import styles from './demo.module.css';

const SLIDES = [
  { src: '/demo-assets/models/model0.png', alt: '', title: 'Editorial' },
  { src: '/demo-assets/models/model3.png', alt: '', title: 'Campaign' },
  { src: '/demo-assets/models/model6.png', alt: '', title: 'Portrait' },
  { src: '/demo-assets/models/model1.png', alt: '', title: 'Studio' },
  { src: '/demo-assets/models/model5.png', alt: '', title: 'Archive' },
];

export default function HorizontalScrollDemo() {
  return (
    <div className={styles.demo}>
      <section className={styles.intro}>
        <p className={styles.label}>Horizontal Scroll</p>
        <h1 className={styles.heading}>Scroll down<br />to move through</h1>
      </section>
      <HorizontalScroll slides={SLIDES} />
      <section className={styles.outro}>
        <p className={styles.outroText}>End of demo</p>
      </section>
    </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';

export default function HorizontalScroll({ slides = [] }) {
  const wrapRef = useRef(null);

  useEffect(() => {
    gsap.registerPlugin(ScrollTrigger);
    const wrap = wrapRef.current;
    if (!wrap) return;

    const mm = gsap.matchMedia();

    mm.add('(min-width: 768px)', () => {
      const panels = gsap.utils.toArray('[data-panel]', wrap);
      if (panels.length < 2) return;

      const tween = gsap.to(panels, {
        x: () => -(wrap.scrollWidth - window.innerWidth),
        ease: 'none',
        scrollTrigger: {
          trigger: wrap,
          start: 'top top',
          end: () => '+=' + (wrap.scrollWidth - window.innerWidth),
          scrub: true,
          pin: true,
          invalidateOnRefresh: true,
        },
      });

      return () => {
        tween.scrollTrigger?.kill();
        tween.kill();
      };
    });

    return () => mm.revert();
  }, []);

  return (
    <section className={styles.wrap} ref={wrapRef}>
      {slides.map((slide, i) => (
        <div key={i} data-panel className={styles.panel}>
          <div className={styles.panelInner}>
            <div className={styles.card}>
              <div className={styles.cardBg}>
                <img
                  src={slide.src}
                  alt={slide.alt}
                  className={styles.cardBgImg}
                />
              </div>
              <div className={styles.cardInner}>
                <h2 className={styles.cardTitle}>{slide.title}</h2>
              </div>
            </div>
          </div>
        </div>
      ))}
    </section>
  );
}
demo.module.css
.demo {
  background: var(--color-bg);
}

.intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  text-align: center;
  padding: var(--space-16);
}

.label {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-text-tertiary);
  margin-bottom: var(--space-6);
}

.heading {
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: var(--weight-bold);
  letter-spacing: -0.03em;
  line-height: var(--leading-tight);
  color: var(--color-text);
}

.outro {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 50vh;
}

.outroText {
  font-size: var(--text-sm);
  color: var(--color-text-tertiary);
  letter-spacing: 0.05em;
}
styles.module.css
/* Local tokens — component is self-contained */
.wrap {
  --color-white: #ffffff;
  --space-l: 1.25rem;
  --space-5xl: 3rem;
  --font-h1: clamp(3rem, 7vw, 6rem);
  --font-h2: clamp(2rem, 5vw, 3.5rem);

  display: flex;
  flex-flow: row;
  min-height: 100dvh;
  overflow: hidden;
}

.panel {
  flex: none;
  width: 100%;
}

.panelInner {
  width: 100%;
  height: 100%;
  padding: var(--space-l);
}

.card {
  border-radius: var(--space-l);
  display: flex;
  flex-flow: column;
  justify-content: flex-end;
  align-items: flex-start;
  width: 100%;
  height: 100%;
  padding: var(--space-5xl);
  position: relative;
  overflow: hidden;
}

.cardBg {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.cardBgImg {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.cardInner {
  position: relative;
  z-index: 1;
}

.cardTitle {
  font-size: var(--font-h1);
  font-weight: var(--weight-medium);
  letter-spacing: -0.04em;
  line-height: 0.95;
  margin: 0;
  color: var(--color-white);
}

@media screen and (max-width: 767px) {
  .wrap {
    flex-flow: column;
  }

  .panel {
    height: 30em;
  }

  .card {
    padding: var(--space-l);
  }

  .cardTitle {
    font-size: var(--font-h2);
  }
}
  • gsap

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.