All components

ScrollTrigger 404 Horizontal Scroll + Parallax

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A 404 page where a pinned full-width headline section is driven by ScrollTrigger scrub: as you scroll, the .wrapper-404 translates horizontally (gsap.to x in vw, power3.out) while four absolutely-positioned image cards parallax sideways and rotate at different rates, revealing a closing outro message.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scroll pin scrolltrigger horizontal-scroll parallax 404 cards editorial

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Pinned Horizontal-Scroll 404 — Giant Serif Headline Pans Sideways While Image Cards Parallax and Rotate

Goal

Build a full-screen black 404 page whose star effect is a pinned, scroll-driven horizontal pan. A colossal white serif headline ("Page Not Found", set at 48vw) lives on a 400vw-wide stage; as the visitor scrolls down, the whole stage is pinned in place and slides left (its x goes 0 → −350vw), so the giant letters sweep across the viewport from right to left. Scattered over the headline are four small rounded image cards that each drift sideways at their own rate and rotate as you scroll — a layered parallax. When the pan completes, a centered closing message ("This page doesn't exist anymore… but that's okay. We'll get you right back on the track!") is revealed on the now-empty black stage. Everything is driven by GSAP ScrollTrigger (scrub + pin) with a distinctive double-smoothed, trailing feel.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. No smooth-scroll library, no canvas/WebGL, no SplitText. Import as:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

All animation code runs inside a DOMContentLoaded listener.

Layout / HTML

.container                                   (the tall scroll driver — height: 1200vh)
  nav > a  "Motionprompts"                    (fixed strip, top-center)

  section.wrapper-404                         (the 400vw-wide pinned stage)
    h1  "Page Not Found"                      (one line, 48vw serif, spans the whole stage)
    .card#card-1 > img                        (4 absolutely-positioned square image cards)
    .card#card-2 > img
    .card#card-3 > img
    .card#card-4 > img

  section.outro                               (the closing message, lower in the container)
    h1  "This page doesn't exist anymore... but that's okay. <br> We'll get you right back on the track!"

script type="module" src="./script.js"

Key classes/ids the JS depends on: .wrapper-404, #card-1#card-4. Nav brand text is the neutral demo name "Motionprompts" — no real brand marks.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; background:#000; overflow-x:hidden; }. img { width:100%; height:100%; object-fit:cover; }.

Palette: pure black #000 background, white #fff text. The image cards supply all the color.

Font: html, body { font-family: "Roslindale Display Condensed"; } — a proprietary high-contrast condensed serif display face. No @font-face/web-font is loaded, so it simply falls back to the platform default serif (Times-like); substitute any elegant high-contrast serif (e.g. a Didone/Times-style display serif). The look is defined by the sizes below, not the family name.

Load-bearing structural CSS (the pin math and transforms depend on this geometry):

  • .container { width:100%; height:1200vh; } — the tall element that creates the page's scroll length.
  • nav { position:fixed; top:0; width:100%; padding:1em; display:flex; justify-content:center; }; a { text-decoration:none; color:#fff; font-size:20px; }.
  • h1 { width:100%; color:#fff; font-size:48vw; font-weight:400; text-align:center; margin:0; } — the giant headline; because it's width:100% inside the 400vw stage, it stretches across the whole stage as one enormous line.
  • .wrapper-404 { position:absolute; top:0; width:400vw; height:100vh; will-change:transform; }the stage GSAP pins and translates on x. It is 4 viewports wide and exactly 1 viewport tall.
  • .card { position:absolute; width:300px; height:300px; background:gray; border-radius:20px; overflow:hidden; } — a fixed 300×300 rounded square; the <img> inside fills it via object-fit:cover.
  • Card anchor positions (percentages are relative to the 400vw stage, so they're spread across its width):
  • #card-1 { top:50%; left:20%; }
  • #card-2 { top:25%; left:40%; }
  • #card-3 { top:45%; left:60%; }
  • #card-4 { top:15%; left:80%; }
  • .outro { position:absolute; top:150vh; width:100%; height:100vh; }, and .outro h1 { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:max-content; font-size:40px; font-weight:lighter; text-align:center; } — a small centered two-line message (NOT the giant 48vw size; this h1 is overridden to 40px).
  • Mobile tweak: @media (max-width:900px){ .wrapper-404 { padding-top:20em; } }.

GSAP effect (exhaustive — this is the whole component)

Two kinds of ScrollTrigger, all created once inside DOMContentLoaded. Note the unusual pattern: every trigger uses scrub and re-issues a short gsap.to() tween inside its onUpdate, so the motion is *double-smoothed* (scrub inertia + a trailing 0.5s power3.out on top). This gives the pan and the cards a soft, laggy, momentum-like glide rather than a rigid 1:1 scrub.

Card data
const cards = [
  { id: "#card-1", endTranslateX: -2000, rotate:  45 },
  { id: "#card-2", endTranslateX: -1000, rotate: -30 },
  { id: "#card-3", endTranslateX: -2000, rotate:  45 },
  { id: "#card-4", endTranslateX: -1500, rotate: -30 },
];
1. Main ScrollTrigger — pin the stage + horizontal pan
ScrollTrigger.create({
  trigger: ".wrapper-404",
  start: "top top",
  end: "+=900vh",
  scrub: 1,
  pin: true,
  onUpdate: (self) => {
    gsap.to(".wrapper-404", {
      x: `${-350 * self.progress}vw`,
      duration: 0.5,
      ease: "power3.out",
    });
  },
});

Precise behavior:

  • pin: true freezes .wrapper-404 in the viewport for the whole active range while the page scrolls; start:"top top" (stage top hits viewport top → the very start of the page) and end:"+=900vh" define the pin length.
  • scrub: 1 ties the trigger's own self.progress to the scrollbar with ~1s catch-up smoothing.
  • onUpdate re-tweens the pinned stage's x on the same .wrapper-404 element every update: target x = −350·progress vw. So progress 0 → x:0 (headline starts, first letters on screen), progress 1 → x:−350vw (the whole 400vw stage has swept left by 3.5 viewports, headline gone, black stage). Each onUpdate overwrites the in-flight 0.5s power3.out tween, layering a trailing ease over the scrub.
2. Per-card ScrollTriggers — parallax x-drift + rotation

For each of the four cards, create an independent ScrollTrigger:

cards.forEach((card) => {
  ScrollTrigger.create({
    start: "top top",
    end: "+=1200vh",
    scrub: 1,
    onUpdate: (self) => {
      gsap.to(card.id, {
        x:      `${card.endTranslateX * self.progress}px`,
        rotate: `${card.rotate * self.progress * 2}`,
        duration: 0.5,
        ease: "power3.out",
      });
    },
  });
});

Precise behavior:

  • These four triggers are NOT anchored to the card elements. In the original they are created with a trigger selector that does not resolve to any real element, so each one effectively measures overall page-scroll progress from the top of the page (start at the page top, running for end:"+=1200vh"). Reproduce that: do not pass a card element as trigger — let each card trigger track the page's scroll progress. (If you tie them to the card elements instead, the progress mapping changes and the parallax rates come out wrong.)
  • Per update, each card is tweened to x = endTranslateX · progress px and rotate = (rotate · progress · 2) degrees — again via a re-fired gsap.to(..., {duration:0.5, ease:"power3.out"}), so cards glide/lag exactly like the stage.
  • End values at progress = 1 (the flung, rotated resting state):
  • #card-1: x −2000px, rotate +90° (45·2)
  • #card-2: x −1000px, rotate −60° (−30·2)
  • #card-3: x −2000px, rotate +90°
  • #card-4: x −1500px, rotate −60°
  • Because the cards' end distance is +=1200vh while the stage's pin is +=900vh, the cards travel/rotate at a different (slower) rate than the stage pans — that rate mismatch is the parallax: cards slide/spin at a 900:1200 (3:4) pace relative to the headline, some clockwise (+45) and some counter-clockwise (−30), so they scatter, tilt, and fly off-screen left as the pan finishes.
Net journey (what's on screen)
  • Start (progress 0): nav "Motionprompts" top-center; the giant serif "P a g…" of "Page Not Found" filling the frame; cards sitting at their anchor spots (e.g. card #1 lower-right).
  • Mid: the stage slides left ("…Found" letters sweep through), each card drifting left and tilting at its own angle, overlapping the letterforms.
  • End (progress 1): stage panned fully −350vw, headline gone, cards flung far left off-screen — an almost-empty black page with the small centered outro line ("This page doesn't exist anymore… but that's okay. We'll get you right back on the track!") revealed.

Assets / images

4 images, each shown inside a 300×300 rounded (border-radius:20px) square card, hard-cropped with object-fit:cover (so source aspect is forgiving — mix of portrait and landscape works). This is an intentionally eclectic, non-matching set (they don't form one series). By role/order:

  1. #card-1 — a moody product still-life: a sleek matte-aluminium gadget (rounded-rectangle device with a circular directional-pad button and a USB-C-style port) shot close-up on a dark charcoal surface with soft dramatic lighting. (Source portrait ~2:3.)
  2. #card-2 — a bold pop-art / comic-style illustration: a woman with stylized blue skin wearing yellow-framed sunglasses and a blue/yellow/black striped sweater, against a flat bright-red background. (Portrait ~2:3.)
  3. #card-3 — another pop-art comic portrait: a woman with a dark bob and blunt fringe, blue skin tones and blue lips, wearing a navy-and-cream Breton-striped top, against a flat bright-blue background. (Portrait ~2:3.)
  4. #card-4 — an aerial landscape photo: a turquoise river meandering in an S-curve through vast green grassland and rolling hills under a pale sky. (Landscape ~3:2.)

Behavior notes

  • Scroll-only, no autoplay — nothing moves until the user scrolls; at rest the stage sits at x:0. There is no loop and no reverse-autoplay; scrolling back up simply rewinds the scrubbed timeline.
  • The whole sequence lives inside the pin. Because the onUpdate tweens x on the *same* pinned .wrapper-404, and the container is 1200vh, the entire pan + card parallax completes within a compact scroll span and then holds — scrolling to the bottom leaves the end/outro state on screen.
  • Desktop-first (the giant 48vw headline and pinned horizontal pan are built for wide viewports); the mobile media query only nudges the stage down with padding-top.
  • Reduced motion: the original has no prefers-reduced-motion guard; all motion is user-scroll-driven, so it's calm when the user isn't scrolling.