All components

Steelworks Landing Page Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

On-load preloader and hero reveal: a white progress bar scales across a dark overlay that wipes away via clip-path, five small rotated images glide in as a centered row with CustomEase, then the side images fly off-screen while the center one expands to a fullscreen hero as nav, headline and social links reveal line by line with SplitText masks.

How it's built

Categorypreloader
Techgsap
GSAP pluginsSplitText, CustomEase
Complexitypage
Performance costmedium
Mobile-safeyes

preloader reveal hero intro images split-text custom-ease clip-path on-load

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

Landing Page Reveal — Progress-Bar Preloader to Gliding-Image-Row Hero

Goal

Build a full-screen editorial landing hero with a cinematic preloader-to-hero reveal that plays automatically once on page load (~7.5 seconds total). First a thin white progress bar draws itself left-to-right across the top of a solid dark overlay, then retracts to the right; the dark overlay then wipes upward via an animated clip-path. Underneath, five small tilted image thumbnails — pre-parked far off-screen to the left — glide in with a custom ease to form a centered horizontal row. The row then splits apart: the two left thumbnails fly off-screen left, the two right thumbnails fly off-screen right, while the center thumbnail simultaneously scales up, un-rotates and un-rounds into a full-bleed hero background. Finally, masked SplitText lines finish the sequence: the nav, the big headline paragraph and the footer contact links all rise up line by line from behind masks. One single GSAP timeline drives the entire sequence.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase. No smooth-scroll library — the page does not scroll during the intro; it is a pure load-triggered timeline. Register the plugins with gsap.registerPlugin(CustomEase, SplitText). Run everything inside document.addEventListener("DOMContentLoaded", …) wrapped in document.fonts.ready.then(…) so the geometry math and SplitText run only after the web font has loaded.

Layout / HTML

Semantic structure (class names are load-bearing — the JS/CSS query them):

<div class="preloader-overlay">
  <div class="preloader"></div>
</div>

<nav>
  <div class="nav-logo">
    <a href="#">Foundry &amp; Form<br />Industrial Design Consultancy</a>
  </div>
  <div class="nav-items">
    <a href="#">Work</a>
    <a href="#">Catalogue</a>
    <a href="#">About</a>
  </div>
</nav>

<section class="hero">
  <div class="intro-img"><img src="..." alt="" /></div>
  <div class="intro-img"><img src="..." alt="" /></div>
  <div class="intro-img hero-img"><img src="..." alt="" /></div>
  <div class="intro-img"><img src="..." alt="" /></div>
  <div class="intro-img"><img src="..." alt="" /></div>

  <div class="hero-content">
    <div class="hero-header">
      <h1>
        We design objects that carry the weight of their own conviction,
        where every curve and joint exists not for beauty but because the
        material demanded it.
      </h1>
    </div>
    <div class="hero-social">
      <p>Say Hello</p>
      <a href="#">info@foundryandform.com</a>
      <a href="#">View Enquiries</a>
    </div>
  </div>
</section>

Notes:

  • Exactly 5 .intro-img wrappers, each holding one <img>. They are all absolutely stacked at full-viewport size (see CSS); JS scales/positions them into a row. The 3rd one additionally has the class .hero-img — it is the one that becomes the full-bleed hero background.
  • .preloader is a thin bar nested inside a full-screen .preloader-overlay panel.
  • Use "Foundry & Form — Industrial Design Consultancy" as the neutral placeholder brand, info@foundryandform.com as the neutral contact. No real brand names.
  • .hero-content (headline paragraph + contact block) sits above the images.

Styling

Font (single web font): DM Sans (variable, ital + opsz + wght) via Google Fonts — @import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");. body { font-family: "DM Sans", sans-serif }.

Palette (only two colors):

  • #fff — all text, the progress bar (white).
  • #0f0f0f — the preloader-overlay panel (near-black).

Global:

  • * { margin:0; padding:0; box-sizing:border-box }.
  • h1 { color:#fff; font-size:3rem; font-weight:400; letter-spacing:-1%; line-height:1.1 }.
  • a, p { color:#fff; text-decoration:none; font-weight:400; letter-spacing:-1%; display:block }.
  • img { width:100%; height:100%; object-fit:cover }.

Key elements and their initial states (the animation depends on these exactly):

  • nav: position:fixed; top:0; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:flex-start; z-index:2. .nav-items: display:flex; gap:4rem.
  • .preloader-overlay: position:fixed; top:0; width:100%; height:100svh; background-color:#0f0f0f; z-index:10. Initial clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%) (full rectangle — covers the whole viewport).
  • .preloader-overlay .preloader: position:absolute; top:0; width:100%; height:0.5rem; background-color:#fff; transform:scaleX(0); transform-origin:left; will-change:transform (a thin white bar pinned to the very top, starting collapsed, growing from the left).
  • .hero: position:relative; width:100%; height:100svh; overflow:hidden.
  • .intro-img: position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; border-radius:0.5rem; transform-origin:center center; will-change:transform (each wrapper is the full viewport, all five stacked; JS scales them down to thumbnails and moves them via x).
  • .hero-content: position:absolute; top:0; left:0; width:100%; height:100svh; padding:15svh 2rem 15svh 2rem; display:flex; flex-direction:column; justify-content:space-between; z-index:2.
  • .hero-header: width:60%.
  • .line: position:relative; will-change:transform (this class is produced by SplitText; see below).

GSAP effect (be exact)

Setup
import gsap from "gsap";
import SplitText from "gsap/SplitText";
import CustomEase from "gsap/CustomEase";
gsap.registerPlugin(CustomEase, SplitText);

CustomEase.create("hop",   "0.9, 0, 0.1, 1");   // steeper in-out — preloader retract + overlay wipe
CustomEase.create("glide", "0.8, 0, 0.2, 1");   // softer in-out — the whole image-row choreography
Compute the image-row geometry (before the timeline)

Each .intro-img is full-viewport-sized and scaled to 0.2, positioned by x-translation to form a horizontal centered row of 5 thumbnails with a fixed 40px gap. Because transform-origin is center center, a thumbnail's on-screen center sits at innerWidth/2 + x; the target x is therefore desiredCenter − innerWidth/2.

const introImages = document.querySelectorAll(".intro-img");
const introImgScale = 0.2;
const introImgGap = 40;                                // px between thumbnails
const introImgRotations = [-15, 5, -7.5, 10, -2.5];    // per-index tilt in degrees

const introImgScaledWidth = window.innerWidth * introImgScale;
const introImgRowWidth     = introImgScaledWidth * 5 + introImgGap * 4;   // 5 thumbs + 4 gaps
const introImgCenteredX    = (window.innerWidth - introImgRowWidth) / 2;  // left edge of the centered row
const introImgOffScreenX   = introImgCenteredX - window.innerWidth * 1.3; // same row, shoved 1.3 vw to the left

introImages.forEach((img, i) => {
  const centeredX =
    introImgCenteredX + i * (introImgScaledWidth + introImgGap) + introImgScaledWidth / 2 - window.innerWidth / 2;
  const offScreenX =
    introImgOffScreenX + i * (introImgScaledWidth + introImgGap) + introImgScaledWidth / 2 - window.innerWidth / 2;

  gsap.set(img, {
    scale: introImgScale,          // 0.2
    x: offScreenX,                 // parked far off-screen left, in row order
    rotation: introImgRotations[i],
    borderRadius: "2.5rem",        // overrides the CSS 0.5rem while it's a thumbnail
  });

  img.dataset.centeredX = centeredX; // stash the row target for later
});
Split the text into masked lines (before the timeline)
SplitText.create("nav a, .hero-header h1, .hero-social p, .hero-social a", {
  type: "lines",
  linesClass: "line",   // each line element gets class "line"
  mask: "lines",        // wrap each line in an overflow-hidden mask
  autoSplit: true,      // re-split on font-load / resize
});
gsap.set(".line", { y: "125%" });  // park every line 125% below its mask (hidden)
Timeline

Everything runs on one timeline with a 1s lead delay:

const tl = gsap.timeline({ delay: 1 });

Position params below are "<" (align to the start of the previously-added tween), "<N" (N seconds after that start), or a string label ("spread" — created at the current timeline end the first time it's used, then reused).

1 — Progress bar draws in (default position → t = 0)

tl.to(".preloader", {
  scaleX: 1,
  duration: 1.5,
  ease: "glide",
  onComplete: () => gsap.set(".preloader", { transformOrigin: "right" }),
});

The white bar grows scaleX 0 → 1 from the left edge over 1.5s. Its onComplete flips transform-origin to right so the next tween collapses it toward the right.

2 — Progress bar retracts (default position → t = 1.5, sequential)

tl.to(".preloader", { scaleX: 0, duration: 1.25, ease: "hop" });

The bar shrinks scaleX 1 → 0 back toward the right edge over 1.25s.

3 — Dark overlay wipes upward (position "<0.75"t = 2.25)

tl.to(".preloader-overlay", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
  duration: 1,
  ease: "hop",
}, "<0.75");

Starting 0.75s after the retract began, the overlay's clip-path collapses its bottom edge up to the top (both bottom coords 100% → 0%), so the near-black panel wipes up and off, uncovering the hero beneath.

4 — Five thumbnails glide into a centered row (loop, each at "<0.025")

introImages.forEach((img) => {
  tl.to(img, {
    x: parseFloat(img.dataset.centeredX),
    duration: 1.5,
    ease: "glide",
  }, "<0.025");
});

Each image tweens x from its off-screen-left position to its row slot over 1.5s (glide). The "<0.025" chains them: the first starts 0.025s after step 3's start (≈ t = 2.275), and each subsequent image starts 0.025s after the previous one (2.300, 2.325, 2.350, 2.375) — a tight cascade so the row assembles left-to-right.

5 — Left pair flies off-screen left (position label "spread" → created at t ≈ 3.875)

tl.to(".intro-img:nth-child(1), .intro-img:nth-child(2)",
  { x: "-100vw", duration: 1.5, ease: "glide" }, "spread");

The 1st and 2nd thumbnails slide out to x: -100vw. The "spread" label does not yet exist, so GSAP creates it at the current end of the timeline (right after the glide-in loop finishes, ≈ t = 3.875) and places this tween there.

6 — Right pair flies off-screen right (position "spread"t ≈ 3.875)

tl.to(".intro-img:nth-child(4), .intro-img:nth-child(5)",
  { x: "100vw", duration: 1.5, ease: "glide" }, "spread");

The 4th and 5th thumbnails slide out to x: 100vw, starting together with step 5 (the "spread" label now exists).

7 — Center thumbnail expands into the hero (position "<" → same start as step 6, t ≈ 3.875)

tl.to(".hero-img", {
  scale: 1,
  x: 0,
  rotation: 0,
  borderRadius: 0,
  duration: 1.5,
  ease: "glide",
}, "<");

Simultaneously with the pairs flying apart, the 3rd/center image (.hero-img) scale 0.2 → 1, x → 0, rotation → 0, borderRadius 2.5rem → 0 — it blooms from a small tilted thumbnail into the full-bleed, upright, square-cornered hero background over 1.5s.

8 — Nav lines rise in (position "<1"t ≈ 4.875)

tl.to("nav .line", { y: "0%", duration: 1, stagger: 0.1, ease: "power3.out" }, "<1");

1s after the hero-expand began, every nav line (the 2-line logo + the 3 nav items) rises from y:125% to 0% inside its mask, staggered 0.1s, power3.out.

9 — Headline lines rise in (position "<" → same start as step 8, t ≈ 4.875)

tl.to(".hero-header .line", { y: "0%", duration: 1, stagger: 0.1, ease: "power3.out" }, "<");

The big headline paragraph reveals line by line the same way, starting together with the nav.

10 — Footer contact lines rise in (position "<0.25"t ≈ 5.125)

tl.to(".hero-social .line", { y: "0%", duration: 1, stagger: 0.1, ease: "power3.out" }, "<0.25");

0.25s after the headline started, the "Say Hello" label, the email and the "View Enquiries" link rise up line by line, staggered 0.1s, power3.out.

Timeline summary (absolute seconds, after the 1s lead delay)

| t (s) | what | |------|------| | 0–1.5 | progress bar draws in scaleX 0→1 from left (glide); on end → transform-origin:right | | 1.5–2.75 | progress bar retracts scaleX 1→0 toward right (hop) | | 2.25–3.25 | dark overlay clip-path wipes upward off screen (hop) | | 2.275–3.875 | 5 thumbnails glide x: offscreen→row, chained +0.025 each (glide) | | 3.875–5.375 | thumbs 1&2 → x:-100vw, thumbs 4&5 → x:100vw (glide) | | 3.875–5.375 | center .hero-img scale 0.2→1, x→0, rotation→0, radius→0 (glide) | | 4.875–~6 | nav lines + headline lines rise y:125%→0%, stagger 0.1 (power3.out) | | 5.125–~6.5 | footer contact lines rise y:125%→0%, stagger 0.1 (power3.out) |

Total runtime ≈ 7.5s including the 1s lead delay.

Ease reference
  • glide = CustomEase.create("glide", "0.8, 0, 0.2, 1") — the softer in-out used for the progress-bar draw-in (step 1) and the entire image-row choreography (steps 4–7).
  • hop = CustomEase.create("hop", "0.9, 0, 0.1, 1") — the steeper in-out used for the progress-bar retract (step 2) and the overlay wipe (step 3).
  • All three SplitText line reveals (nav, headline, footer) use power3.out.

Assets / images

Five moody editorial images, object-fit: cover (aspect ratio is flexible since each is cover-cropped — each wrapper is full-viewport, so as a 0.2 thumbnail it reads as a small viewport-aspect tile, and the center one ends full-bleed). The set here mixes four landscape frames with one portrait frame; only the count and order matter to the motion. What the current images actually show:

  • Image 1 — exits to the left. Landscape. A warm sepia-toned macro close-up of an eyebrow, brow bone and lash line; dominant colors are monochrome browns and beige skin tones.
  • Image 2 — exits to the left. Landscape. A centered waist-up portrait of a young man with dark curly hair in an olive-green suit and tie, shot against a soft painterly backdrop; dominant colors are olive green, warm peach/apricot and pale teal.
  • Image 3 (.hero-img) — the star: it becomes the fullscreen hero background. Portrait orientation, cover-cropped to full-bleed. A high-contrast black-and-white side profile of a woman with a short dark bob in a black blazer against a pale off-white ground; dominant colors are grayscale black, white and light grey.
  • Image 4 — exits to the right. Landscape. A minimal product-style still of a matte silver twisted-torus (wavy metal ring) sculpture resting on a flat neutral grey surface; dominant colors are brushed silver and mid grey.
  • Image 5 — exits to the right. Landscape. A moody still-life of purple bearded iris blooms with yellow-and-white throats against a deep glossy red tiled backdrop; dominant colors are violet-purple, oxblood red and touches of green and yellow.

Each thumbnail is pre-tilted by index with rotations [-15, 5, -7.5, 10, -2.5] degrees. If you swap in your own images, keep the roles by position (two exit left, center is the hero, two exit right) — texture/portrait/still-life all work. If you have fewer than five, repeat to reach five. No brand logos.

Behavior notes

  • Autoplay once on load (DOMContentLoadeddocument.fonts.ready); no scroll, hover or click triggers. The page does not scroll during the intro.
  • The geometry (window.innerWidth) is measured once at load, so the row is centered for the initial viewport size; autoSplit: true keeps the SplitText lines valid across font-load/resize.
  • Uses 100svh (small viewport height) so mobile browser chrome doesn't clip the full-height overlay/hero.
  • Keep the will-change hints (transform on .preloader, .intro-img, .line) — they matter for smooth transform and clip-path animation.
  • Responsive (@media max-width: 1000px): .nav-items becomes a right-aligned vertical column (flex-direction:column; align-items:flex-end; gap:0); .hero-content padding drops to 15svh 2rem 2rem 2rem; .hero-header widens to width:100%. The animation itself is unchanged — only layout adapts.

Images

This component ships with 5 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/steelworks-landing-page-reveal/img-1.jpg
https://motionprompts.dev/c/steelworks-landing-page-reveal/img-2.jpg
https://motionprompts.dev/c/steelworks-landing-page-reveal/img-3.jpg
https://motionprompts.dev/c/steelworks-landing-page-reveal/img-4.jpg
https://motionprompts.dev/c/steelworks-landing-page-reveal/img-5.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--ink, --paper, --brass, --steel, --white. 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 *, 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: one script that waits for the DOM, reaches into the page with plain selector strings, and never has to undo itself once the seven-and-a-half-second reveal finishes. React withdraws all three of those guarantees at once, and it does it quietly — the reveal plays, looks right, and the failure only surfaces on the second mount.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that means two SplitText passes over the same nav a, .hero-header h1, .hero-social p and .hero-social a nodes — the second pass wraps lines that are already wrapped in .line masks, one level deeper than the CSS expects — and two tl timelines racing to drive the same five .intro-img elements through the same off-screen-to-row-to-spread choreography, so the thumbnails jitter or .hero-img never settles at its full-bleed scale: 1. Worse, the whole sequence is gated behind document.fonts.ready, a promise that has every opportunity to resolve only after the first, throwaway mount has already been torn down — at which point its callback would still try to split and animate a page that, from React's perspective, no longer exists. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script's own bottom branch is if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();, guarding a boot() that only runs when this isn't loaded inside the catalogue's own tuning harness (the window.MP.register({ defaults: DEFAULTS, mount }) branch). Neither survives the port: the harness branch has no equivalent in a shipped component, and the readiness guard is redundant once useEffect guarantees the DOM is already committed. Drop both, along with boot and the DEFAULTS/config indirection, and move the body of mount() into a useEffect with an empty dependency array — starting from the document.fonts.ready.then(...) call, not from inside it.

*(2) Element lookups* — The markup this component owns is three top-level siblings, not one: .preloader-overlay, nav and section.hero all sit directly under the document root with nothing wrapping them (see Layout/HTML above). Give the component a single root element — a Fragment won't do, since gsap.context needs a real DOM node to scope to — and render all three inside it, then put a ref on that wrapper and scope every lookup to it. That also covers the one query in this script that isn't a GSAP call: document.querySelectorAll(".intro-img"), used to compute introImgCenteredX/introImgOffScreenX and stash dataset.centeredX on each image before the timeline reads it back. That line sits outside any tween or trigger, so gsap.context's selector-text scoping never reaches it on its own — call gsap.utils.toArray(".intro-img") in its place, from inside the context (below), so it resolves against this component's own five images rather than whichever copy of .intro-img a StrictMode remount happens to leave in the DOM first.

*(3) Cleanup* — The destroyed flag this component already carries (let destroyed = false at the top of mount(), checked as if (destroyed) return inside the .then()) is the exact cancellation idiom a React port needs — keep the idiom, just fold it into the effect:

useEffect(() => {
  let cancelled = false;
  let split = null;
  let tl = null;
  let self;
  const ctx = gsap.context((ctxSelf) => { self = ctxSelf; }, rootRef);

  document.fonts.ready.then(() => {
    if (cancelled) return;
    self.add(() => {
      const introImages = gsap.utils.toArray(".intro-img");
      // the geometry math against introImages, unchanged, then:
      split = SplitText.create("nav a, .hero-header h1, .hero-social p, .hero-social a", {
        type: "lines",
        linesClass: "line",
        mask: "lines",
        autoSplit: true,
      });
      gsap.set(".line", { y: "125%" });
      tl = gsap.timeline({ delay: 1 });
      // the ten-step sequence exactly as built above, ending on
      // nav .line / .hero-header .line / .hero-social .line
    });
  });

  return () => {
    cancelled = true;
    tl?.kill();
    ctx.revert();
    split?.revert();
  };
}, []);

The effect itself stays synchronous — it starts the document.fonts.ready chain and returns immediately, and it is never itself declared async — so React always gets a real cleanup function back, never a dangling promise. The setup only actually runs once fonts have resolved *and* cancelled is still false, which is what keeps a StrictMode unmount landing mid-font-load from splitting and animating a header that has already been removed.

self.add(...) here is the one-argument form: it runs the callback immediately, inside the context, which is what makes the plain string selectors inside it — ".preloader", "nav .line", ".hero-header .line", ".hero-social .line", the SplitText.create target list — resolve against the root ref instead of the whole document. Nothing in this timeline needs calling back in from an outside event: the reveal autoplays once and never responds to a click or a hover, so there's no reason to reach for the name-and-call-later overload instead.

gsap.registerPlugin(CustomEase, SplitText) and the two CustomEase.create calls that define hop and glide belong at module scope, exactly where the current script already puts them — they don't depend on any DOM node this component owns, and redefining the same two eases on every mount is harmless but pointless.

Order the cleanup the same way the existing destroy() already does, and for the same reason: kill tl and revert the context *before* reverting split. The context's revert restores every inline transform, clipPath and borderRadius GSAP wrote onto .preloader, .preloader-overlay, the five .intro-img elements and .hero-img, and stops the timeline from reaching for .line nodes mid-tween; only once that has settled is it safe to call split.revert() and collapse the masked .line spans back into the plain nav a, h1, p and a elements SplitText found. Revert it out of order — or skip it — and the next mount's SplitText.create finds spans already sitting inside spans, and masks lines that sit one level deeper than the CSS expects.