Full-Screen Preloader → Curtain-Wipe Landing Reveal (counter + layered clip-path wipe)
Goal
Build an auto-playing landing-page preloader for a fictional design studio called New Reality. On load a full-screen black panel fills the viewport; a numeric counter races 0 → 100 in the center while an editorial label ("NEW REALITY") reveals letter-by-letter from above, holds, then slides letter-by-letter downward out of view. At ~3 s the whole preloader scales down to 0.5 and then, in a tightly staggered sequence, three stacked full-screen layers each collapse upward — a black panel and a red panel animate their height to 0, and a hero-image layer wipes away via clip-path — peeling back like curtains to uncover the site underneath: a giant 20vw headline whose seven letters rise up into place, and two footer thumbnails that wipe open from the left. Everything is purely time-delayed on page load — no scroll, hover, or click.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no Lenis/smooth-scroll. The page never scrolls; every animation is a load-triggered gsap.to / gsap.from with a delay.
import gsap from "gsap";
Two effects are not GSAP and must be reproduced as described:
- The counter (0 → 100) is a plain-JS
setTimeoutloop with random increments — not a tween. - The label letter reveal was originally an anime.js v3.2.2 timeline using
easeOutExpo. Reproduce it with GSAP: anime'seaseOutExpois mathematically identical to GSAP'sexpo.out(1 − 2^(−10·t)), so twogsap.fromTotweens reproduce it faithfully with a single dependency (see the GSAP section). Do not add anime.js.
All animation code runs immediately on module execution (no DOMContentLoaded wrapper is required).
Layout / HTML
Two sibling blocks: a .container holding the preloader layers, and a .site-content block (the real page) that sits behind everything (z-index: -2). Class/tag names are load-bearing — the JS and CSS query them exactly.
<div class="container">
<div class="pre-loader">
<div class="loader"></div> <!-- opaque BLACK full-screen panel -->
<div class="loader-bg"></div> <!-- RED full-screen panel, behind .loader -->
</div>
<div class="loader-content"> <!-- centered counter + label, on top -->
<div class="count"><p>0</p></div>
<div class="copy"><p class="ml16">New reality</p></div>
</div>
<div class="loader-2"></div> <!-- full-screen HERO IMAGE layer, behind the panels -->
</div>
<div class="site-content">
<nav>
<div class="logo"><a href="#">New Reality</a></div>
<div class="links">
<a href="#">Info</a>
<a href="#">Portfolio</a>
<a href="#">Contact</a>
</div>
</nav>
<div class="header"> <!-- one <h1> per letter → spells "NEWREAL" across the viewport -->
<h1>N</h1><h1>e</h1><h1>w</h1><h1>r</h1><h1>e</h1><h1>a</h1><h1>l</h1>
</div>
<footer>
<div class="footer-copy">
<p>New Reality is a design studio based in Tokyo, Japan. We work with many
companies to build and proactively deliver engaging brand experiences. We are
unique in our ability to take a strategic approach while being visually driven.</p>
</div>
<div class="footer-nav">
<div class="img"></div> <!-- footer thumbnail 1 (CSS background-image) -->
<div class="img"></div> <!-- footer thumbnail 2 (CSS background-image) -->
</div>
</footer>
</div>
Notes:
- The seven
.header h1each contain one letter; withtext-transform: uppercasethey read NEWREAL spread across the full width, each letter in its own equal-flex column. - The two
.imgdivs get their images from CSSbackground-image(not<img>tags).
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100vw; height:100vh; font-family:"Neue Montreal" }.
Palette (literal values):
- Loader panel: black
#000, text white#fff. - Second panel
.loader-bg:red(#ff0000). - Site text / links: black
#000on the page's default (transparent → white) background.
Typography:
- Body: "Neue Montreal" (a neutral grotesque sans; fall back to a clean
sans-serif). .copylabel and.header h1: "PP Editorial Old" (a high-contrast editorial serif; fall back to aserif). These are custom fonts and are simply referenced by name — load them via CDN/@font-faceif available, otherwise the serif/sans fallbacks are fine..copy:font-size: 30px; text-transform: uppercase; line-height: 1..header h1:font-size: 20vw; font-weight: 500; text-transform: uppercase; line-height: 1; text-align: center.
Key positioning & the states the animation depends on (initial values matter):
.pre-loader—position: fixed; top:0; width:100%; height:100%; clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(a full-rect clip; present so the panel is a clean fixed layer)..loader—position: absolute; top:0; width:100%; height:100%; background:#000; color:#fff; display:flex; justify-content:center; align-items:center(opaque black, full screen; starts atheight:100%)..loader-bg—position: absolute; top:0; width:100%; height:100%; background:red; z-index:-1(red panel sitting behind the black one; starts atheight:100%)..loader-content—position: absolute; top:50%; left:50%; transform: translate(-50%,-50%); display:flex; width:400px; z-index:2; color:#fff(the centered counter+label, on top of everything)..count—flex: 2; text-align: right; line-height: 1; padding: 0 1em..copy—flex: 6(the serif label, styled as above)..ml16—overflow: hidden(this masks the letters as they slide in/out)..ml16 .letter—display: inline-block; line-height: 1em(JS wraps each non-space character in<span class="letter">)..loader-2—position: absolute; top:0; width:100%; height:100%; background: url(<hero image>) no-repeat 50% 50%; background-size: cover; z-index:-1; clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(the hero-image layer, starts as a full-rect clip = fully visible, sitting behind the red/black panels)..site-content—position: relative; z-index: -2(the real page, behind all preloader layers).nav—width:100%; padding:2em; display:flex.nav > div { flex:1 }..links { display:flex; justify-content:flex-end; gap:5em }.a { text-decoration:none; color:#000 }..header—display:flex; padding:1em..header h1 { flex:1; position:relative }(see typography). Nooverflow:hiddenhere.footer—position: fixed; bottom:0; width:100%; display:flex; align-items:flex-end; padding:2em.footer > div { flex:1 }..footer-copy p { width:50% }..footer-nav { display:flex; gap:2em; justify-content:flex-end }..img—width:225px; height:150px; background-size:cover; background-position:50% 50%andclip-path: polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)(a zero-width sliver pinned to the left edge → the image is initially hidden and reveals by wiping open rightward). Assign each a background image (.img:nth-child(1),.img:nth-child(2)).
GSAP effect (be exhaustive)
Fire everything at module load. There is no master timeline — these are independent gsap tweens plus one plain-JS counter and a two-step letter animation. Times below are seconds from load.
1. Counter 0 → 100 (plain JS, not GSAP)
function startLoader() {
const el = document.querySelector(".count p");
let value = 0;
(function tick() {
if (value < 100) {
value = Math.min(value + (Math.floor(Math.random() * 10) + 1), 100); // +1..+10
el.textContent = value;
setTimeout(tick, Math.floor(Math.random() * 200) + 25); // 25..225 ms
}
})();
}
startLoader();
The number jumps up in random steps of 1–10 with random 25–225 ms gaps, hitting 100 around ~2–3 s. It is then faded out by GSAP (steps 2 & 5 below).
2. Label letters — reveal, hold, exit (GSAP expo.out, reproducing the anime.js easeOutExpo timeline)
First wrap each visible character of .ml16 in a span, then run two fromTo tweens:
const wrap = document.querySelector(".ml16");
wrap.innerHTML = wrap.textContent.replace(/\S/g, "<span class='letter'>$&</span>");
const letters = document.querySelectorAll(".ml16 .letter"); // "Newreality" → 10 letters (space not wrapped)
// Phase 1 — reveal: each letter drops in from 100px above to 0
gsap.fromTo(letters,
{ y: -100 },
{ y: 0, ease: "expo.out", duration: 1.5, stagger: 0.03 } // stagger 30ms per letter, from load
);
// Phase 2 — exit: each letter slides 100px downward out of view
gsap.fromTo(letters,
{ y: 0 },
{ y: 100, ease: "expo.out", duration: 3, stagger: 0.03, delay: 3.77 }
);
Timing rationale (matching the original anime.js timeline): phase 1 is translateY [-100 → 0], 1.5 s, easeOutExpo, per-letter delay 30·i ms. Phase 2 is appended after phase 1 ends (~1.77 s) with an additional per-letter delay of 2000 + 30·i ms → each letter begins its downward exit at ≈ 3.77 s + 30 ms·i, animating translateY [0 → 100] over 3 s. The overflow:hidden on .ml16 masks the letters through both phases.
3. Counter fade (early)
gsap.to(".count", { opacity: 0, delay: 3.5, duration: 0.25 });
4. Preloader scale-down
gsap.to(".pre-loader", { scale: 0.5, ease: "power4.inOut", duration: 2, delay: 3 });
The entire preloader (black + red panels) shrinks from scale 1 → 0.5 toward the viewport center, starting at 3 s over 2 s.
5. Counter fade (final, redundant with #3)
gsap.to(".count", { opacity: 0, ease: "power2.inOut", duration: 0.5, delay: 3.75 });
6. Black panel collapses upward
gsap.to(".loader", { height: "0", ease: "power4.inOut", duration: 1.5, delay: 3.75 });
.loader is top:0, so animating height 100% → 0 collapses the black panel upward.
7. Red panel collapses upward
gsap.to(".loader-bg", { height: "0", ease: "power4.inOut", duration: 1.5, delay: 4 });
Same collapse for the red panel, 0.25 s behind the black one — so you glimpse the red layer as the black one clears.
8. Hero-image layer wipes away (clip-path)
gsap.to(".loader-2", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
ease: "power4.inOut", duration: 1.5, delay: 3.5,
});
The hero-image layer's clip goes from a full rectangle (0 0, 100% 0, 100% 100%, 0 100%) to a zero-height line at the top (both bottom corners pulled up to y:0) — the image collapses/wipes upward and disappears, exposing the .site-content behind it. Because the black (#6) and red (#7) panels are collapsing at almost the same moment, the three layers peel back like stacked curtains.
9. Headline letters rise in
gsap.from(".header h1", { y: 200, ease: "power4.inOut", duration: 1.5, delay: 3.75, stagger: 0.05 });
The seven NEWREAL letters each animate from y:200 (200px below) up to their resting position, staggered 0.05 s.
10. Footer thumbnails wipe open
gsap.to(".img", {
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
ease: "power4.inOut", duration: 1.5, delay: 4.5, stagger: 0.25,
});
Each .img clip animates from its CSS zero-width left sliver to a full rectangle → the two thumbnails wipe open left-to-right, 0.25 s apart.
Approximate timeline (seconds from load)
| t | event | |---|-------| | 0 | counter starts ticking; label letters reveal from above (phase 1) | | ~2–3 | counter reaches 100 | | 3.0 | preloader begins scaling 1 → 0.5 (#4) | | 3.5 | counter fade #3; hero-image layer begins clip-wipe up (#8) | | 3.75 | final counter fade (#5); black panel height→0 (#6); headline letters rise (#9); label letters begin downward exit (phase 2) | | 4.0 | red panel height→0 (#7) | | 4.5 | footer thumbnails begin wiping open (#10) | | ~5.5–6.5 | all reveals settle; site fully visible |
Total intro ≈ 6.5 s.
Assets / images
- 1 hero background image on
.loader-2, full-bleed (background-size: cover, landscape ~16:9). A warm, richly-toned editorial/artwork image — it flashes into view mid-transition as the panels peel back, then wipes upward. Any bold, warm full-bleed image works in this role; it is only visible for a moment during the curtain wipe. - 2 footer thumbnails on the
.imgdivs, each rendered 225×150 px (~3:2 landscape),background-size: cover. Editorial/photographic in tone (e.g. a motion-blurred warm-monochrome crowd, and a moody hand-holding-glass-crystal shot). They reveal by clip-path wiping open from the left. Cropping via cover is expected.
No client branding — the demo studio name is "New Reality" ("Newreal" in the split headline). Keep all copy neutral/placeholder.
Behavior notes
- Autoplay once on load; no scroll, hover, or click anywhere. The page itself does not scroll.
- Keep the layer stacking exact:
.loader-contentz-index:2(top) →.loader(black) →.loader-bg(red,z-index:-1) →.loader-2(hero,z-index:-1) →.site-content(z-index:-2, bottom). The reveal reads correctly only with this order. - The
overflow:hiddenon.ml16and the exact initialclip-pathvalues on.loader-2and.imgare essential — they are what the tweens animate away from. - Responsive
@media (max-width: 900px):footerbecomesflex-direction: column; gap: 2em,.footer-copy pwidens towidth:100%, and.footer-navbecomeswidth:100%; justify-content:space-between. The animation logic is unchanged. - No reduced-motion handling exists in the original; add a
prefers-reduced-motionguard if desired, but it is not part of the reference behavior.