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.

Images

This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/404/img-1.jpg
https://motionprompts.dev/c/404/img-2.jpg
https://motionprompts.dev/c/404/img-3.jpg
https://motionprompts.dev/c/404/img-4.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bg, --paper, --ink, --gray, --gray-read, --red, --hairline. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: a script that waits for DOMContentLoaded, reaches into the page with plain selector strings, and never has to undo anything. React withdraws all three of those guarantees at once, and it does it quietly — the pin and the pan look right on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: here that means two ScrollTriggers pinning the same .wrapper-404 and disagreeing about the same scrub, plus two full sets of the four card triggers, each pair re-tweening #card-1#card-4 on every scroll tick. The visible symptom is a stage that pans at roughly twice the rate, or cards whose x/rotate visibly stutter between two competing targets, and it will not reproduce in a production build, because React only does the double mount in development.

*(1) The entry point* — the whole effect lives inside document.addEventListener("DOMContentLoaded", function () {...}): the cards array (the four { id, travelVW, rotate } descriptors) is declared there, and so are all five ScrollTrigger.create calls — the pin-and-pan trigger on .wrapper-404 and one trigger per card. By the time a React component mounts, DOMContentLoaded has already fired, so that listener is registered and never called: nothing pins, nothing scrubs, the stage just sits at rest and the container scrolls past it like an ordinary tall page, with nothing in the console to explain why. Delete the listener and move its entire body — the cards array and all five ScrollTrigger.create calls — directly into a useEffect with an empty dependency array.

*(2) Element lookups* — the pin trigger is the string ".wrapper-404", each card trigger is keyed off card.id (one of "#card-1""#card-4"), and every onUpdate re-resolves that same string again to re-tween the stage or the card it belongs to. All of this assumes there is exactly one .wrapper-404 and one of each #card-N in the whole document. Give the pinned stage a root ref, render it on .wrapper-404 (or the element that wraps it and the four cards), and scope every one of these lookups to it. That matters more here than in most components in this catalogue: #card-1#card-4 are IDs, and during the StrictMode remount two copies of the subtree exist for an instant, each declaring its own #card-1. A bare "#card-1" — whether handed to ScrollTrigger.create as a trigger or to gsap.to as a target — resolves to whichever copy happens to come first in the document, not necessarily the one this effect run owns.

*(3) Cleanup* — wrap the pin-and-pan trigger and all four card triggers in one gsap.context scoped to the root ref, and revert that context in the cleanup:

useEffect(() => {
  const ctx = gsap.context((self) => {
    const cards = [ /* the four { id, travelVW, rotate } descriptors, unchanged */ ];
    const q = self.selector; // scopes "#card-1" etc. to this component's own copy
    ScrollTrigger.create({ trigger: q(".wrapper-404")[0], pin: true, scrub: true /* … */ });
    cards.forEach((card) =>
      ScrollTrigger.create({ trigger: q(card.id)[0], scrub: true /* … */ })
    );
  }, rootRef);
  return () => ctx.revert();
}, []);

ctx.revert() kills all five ScrollTriggers and unpins the stage in a single call, which is exactly what prevents the doubled pin and the doubled card triggers described above. gsap.registerPlugin(ScrollTrigger) already sits at module scope in the source, above the listener — leave it there; it does not belong inside the effect.

One thing ctx.revert() does not reach: every onUpdate here doesn't just read self.progress, it calls gsap.to(".wrapper-404", …) or gsap.to(card.id, …) again, by string, on every scroll tick — that's the trailing, momentum-like glide this component layers on top of the raw scrub. Those gsap.to() calls run from inside a callback GSAP itself invokes long after the synchronous function passed to gsap.context() has already returned, so, unlike the ScrollTrigger.create() calls that spawned them, they are never recorded into the context. Reverting kills the triggers, which stops onUpdate from firing again after unmount — so no new stray tween gets created — but it will not reach back and cancel a re-tween that was already mid-flight at the exact moment of teardown, since that particular tween instance was never added to the context to begin with. In practice that is a short animation finishing against a node that may already be detached, not a growing leak, but it is the reason to resolve the target inside onUpdate through the same scoped q() shown above (gsap.to(q(".wrapper-404")[0], …), gsap.to(q(card.id)[0], …)) instead of the bare global string: it keeps every one of these re-issued tweens, present and future, pointed only at the copy this effect actually owns.