AnimationsIntermediateApril 8, 2026

Parallax Scroll

A section with large headings, letter-by-letter random parallax offsets, and three images drifting upward at different speeds (0, -150px, -250px) as you scroll. Uses Framer Motion useScroll and useTransform.

View Full Demo →

Parallax

Scroll

smooth motion

index.jsx
"use client";
import { useRef, useMemo } from "react";
import Image from "next/image";
import { motion, useScroll, useTransform } from "framer-motion";
import styles from "./styles.module.css";

// Each letter needs its own useTransform — extracted to avoid hooks-in-map
function Letter({ char, progress, yOffset }) {
  const y = useTransform(progress, [0, 1], [0, yOffset]);
  return (
    <motion.span className={styles.letter} style={{ top: y }}>
      {char === " " ? "\u00A0" : char}
    </motion.span>
  );
}

// Each image needs its own y transform
function ParallaxImage({ src, progress, yRange }) {
  const y = useTransform(progress, [0, 1], yRange);
  return (
    <motion.div className={styles.imageContainer} style={{ y }}>
      <Image src={src} alt="" fill className={styles.img} sizes="28vw" />
    </motion.div>
  );
}

export default function ParallaxScroll({ word = "smooth motion", images = [] }) {
  const containerRef = useRef(null);

  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start end", "end start"],
  });

  // Pre-compute stable random offsets — computed once, not on every render
  const letterOffsets = useMemo(
    () => word.split("").map(() => Math.floor(Math.random() * -75) - 25),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [word]
  );

  // Three speed tiers for images
  const Y_RANGES = [[0, -50], [0, -150], [0, -250]];

  return (
    <div ref={containerRef} className={styles.container}>
      {/* ── Headings ── */}
      <div className={styles.body}>
        <motion.h1
          className={styles.heading}
          style={{ y: useTransform(scrollYProgress, [0, 1], [0, -50]) }}
        >
          Parallax
        </motion.h1>
        <h1 className={styles.heading}>Scroll</h1>

        {/* ── Letter-by-letter random parallax ── */}
        <div className={styles.word}>
          <p>
            {word.split("").map((char, i) => (
              <Letter
                key={i}
                char={char}
                progress={scrollYProgress}
                yOffset={letterOffsets[i]}
              />
            ))}
          </p>
        </div>
      </div>

      {/* ── Images at three different parallax speeds ── */}
      <div className={styles.images}>
        {images.slice(0, 3).map((src, i) => (
          <ParallaxImage
            key={i}
            src={src}
            progress={scrollYProgress}
            yRange={Y_RANGES[i] ?? [0, -50]}
          />
        ))}
      </div>
    </div>
  );
}
styles.module.css
.container {
  display: flex;
  flex-direction: column;
  gap: 5vh;
  padding: 10vh 5vw;
  min-height: 200vh; /* scroll space for the parallax */
  background: #0d0d0d;
  color: #f5f5f5;
}

/* ── Left column: headings + word ── */

.body {
  display: flex;
  flex-direction: column;
  gap: 0.25em;
}

.heading {
  font-size: clamp(3rem, 10vw, 9rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  margin: 0;
  line-height: 0.9;
}

.word {
  margin-top: 1rem;
}

.word p {
  font-size: clamp(1.5rem, 4vw, 3rem);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
}

/* Each letter uses position: relative so `top` (set via Framer Motion) works */
.letter {
  position: relative;
  display: inline-block;
}

/* ── Right/bottom: images ── */

.images {
  display: flex;
  gap: 1.5rem;
  align-items: flex-start;
  padding-top: 5vh;
}

.imageContainer {
  position: relative;
  flex: 1;
  aspect-ratio: 3 / 4;
  overflow: hidden;
  border-radius: 0.5rem;
}

.img {
  object-fit: cover;
}

@media (max-width: 768px) {
  .images {
    flex-direction: column;
  }

  .imageContainer {
    width: 100%;
    aspect-ratio: 4 / 3;
  }
}
  • framer-motion

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.