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 & 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-imgwrappers, 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. .preloaderis a thin bar nested inside a full-screen.preloader-overlaypanel.- Use "Foundry & Form — Industrial Design Consultancy" as the neutral placeholder brand,
info@foundryandform.comas 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. Initialclip-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 viax).
.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 (
DOMContentLoaded→document.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: truekeeps 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-changehints (transformon.preloader,.intro-img,.line) — they matter for smooth transform and clip-path animation. - Responsive (
@media max-width: 1000px):.nav-itemsbecomes a right-aligned vertical column (flex-direction:column; align-items:flex-end; gap:0);.hero-contentpadding drops to15svh 2rem 2rem 2rem;.hero-headerwidens towidth:100%. The animation itself is unchanged — only layout adapts.