Landing Page Reveal Animation (rolling counter + Flip image reveal + masked line text)
Goal
Build an auto-playing landing-page intro for a design studio: three vertical rolling digit columns count from 0 to 100 in the bottom-right corner while a lighter background panel wipes up from the bottom and 15 rounded photo thumbnails pop in (scale 0 → 1) stacked in the top-left corner. When the "load" finishes, the counter fades out and GSAP Flip animates every thumbnail from the top-left corner to the bottom-right corner with a staggered scale-punch (each image blows up to 2.5× mid-flight and settles back to 1). Finally, divider lines draw in and every piece of text (nav, giant heading, side info, footer) rises into view line-by-line through overflow masks via SplitText. Everything runs once on DOMContentLoaded — no scroll, no hover.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins Flip and SplitText:
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(Flip, SplitText);
No Lenis, no ScrollTrigger — this is a load-triggered, single-viewport hero.
Layout / HTML
One <section class="hero"> filling the viewport, containing (in this order):
<section class="hero">
<div class="hero-bg"></div>
<div class="counter">
<div class="counter-1 digit"></div>
<div class="counter-2 digit"></div>
<div class="counter-3 digit"></div>
</div>
<div class="images-container">
<!-- 15 of these -->
<div class="img"><img src="..." alt="" /></div>
</div>
<nav>
<div class="logo-name"><a href="#">Omno</a></div>
<div class="nav-items">
<div class="links">
<a href="#">Portfolio</a>
<p>/</p>
<a href="#">About</a>
</div>
<div class="cta"><a href="#">Contact Us</a></div>
</div>
<div class="divider"></div>
</nav>
<div class="sidebar">
<div class="logo"><img src="..." alt="" /></div>
<div class="divider"></div>
</div>
<div class="header">
<h1>Visual engineering for modern brands</h1>
</div>
<div class="site-info">
<h2>A design team focused on brands websites, apps and products</h2>
<div class="divider"></div>
<div class="site-info-copy">
<p>Award-winning creative studio</p>
<p>Operating since 2019</p>
</div>
</div>
<div class="hero-footer">
<h2>Watch showreel</h2>
</div>
</section>
The three .counter-* divs start empty — their digit children are generated by JS (see below).
Styling
Palette (CSS custom properties):
--bg: #f1efe7— warm off-white page/hero background--fg: #1f1f1f— near-black text--loader-bg: #e0e0d8— slightly darker grey-beige for the wipe panel--stroke: rgba(0, 0, 0, 0.2)— divider lines
Typography: "PP Neue Montreal" (import via @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");), everything weight 500.
h1:6rem,letter-spacing: -0.1rem,line-height: 1.1h2:1.75rem,letter-spacing: -0.02rem,line-height: 1.1a, p:1rem,line-height: 1,overflow: hidden, color--fg, no underline.logo-name a:1.5rem
Global reset (* { margin:0; padding:0; box-sizing:border-box }); all img are width/height: 100%; object-fit: cover.
Key positioning (this is what makes the effect work):
.hero:position: relative; width: 100vw; height: 100svh; background: var(--bg); overflow: hidden..hero-bg: absolutely fills the hero,background: var(--loader-bg),
transform-origin: bottom, initial transform: scaleY(0%) — it wipes upward.
.counter: `position: fixed; right: 3rem; bottom: 2rem; display: flex; height: 120px;
font-size: 120px; line-height: 150px; -webkit-text-stroke: 2px var(--fg); clip-path: polygon(0 0, 100% 0, 100% 120px, 0 120px)` — the clip-path is the mask that hides digits above/below the 120px window. The digits keep the inherited dark text color, so they render as solid near-black numerals thickened by the 2px stroke.
.counter-1, .counter-2, .counter-3:position: relative; top: -15px(optical centering
inside the clip window).
.num1offset1 { position: relative; right: -30px }and
.num1offset2 { position: relative; right: -15px } — kerning fixes for the narrow "1" glyph.
.images-container: absolutely fills the hero..images-container .img: `position: absolute; top: 1.5rem; left: 1.5rem; width: 20%;
aspect-ratio: 5/3; border-radius: 0.75rem; overflow: hidden` — all 15 stack in the same top-left spot.
.images-container .img.animate-out: `top: unset; left: unset; bottom: 1.5rem;
right: 1.5rem` — same size, anchored bottom-right. This class swap is what Flip animates.
nav: `position: relative; width: 100vw; height: 5rem;
padding: 1.5rem 1.5rem 1.5rem 7.5rem; display: flex; justify-content: space-between; align-items: center. .nav-items is flex with gap: 7.5rem; .links flex with gap: 0.5rem. Its .divider is absolute at the bottom, full width, height: 1px, transform-origin: left, initial transform: scaleX(0%)`.
.sidebar: absolute, top-left,width: 5rem; height: 100svh; padding-top: 1.5rem, flex
centered horizontally, items at the top. .sidebar .logo: width: 2rem; aspect-ratio: 1; transform: scale(0). .sidebar .divider: absolute at its right edge, width: 1px; height: 100svh; transform-origin: top; transform: scaleY(0%).
- All
.dividers sharebackground-color: var(--stroke). .header: absolute,top: 35%; left: 7.5rem; transform: translateY(-50%); width: 60%..site-info: absolute,right: 1.5rem; top: 60%; transform: translateY(-50%); width: 20%,
flex column gap: 1rem. Its .divider is width: 100%; height: 1px; transform-origin: left; scaleX(0%). .site-info-copy is a flex column with gap: 0.25rem.
.hero-footer: absolute,bottom: 1.5rem; left: 7.5rem.- SplitText mask styles:
.line { overflow: hidden }and
.line span { position: relative; display: block; transform: translateY(125%); will-change: transform } — every line starts pushed below its mask.
GSAP effect (exhaustive)
Everything happens inside a single DOMContentLoaded listener. Order of setup, then one main timeline plus three independent counter tweens.
1. Text splitting (setup, before any animation)
For every h1, h2, p, a on the page, run SplitText.create(element, { type: "lines", linesClass: "line" }). Then, for each produced .line element, replace its innerHTML with <span>{its textContent}</span> — i.e. manually wrap each line's text in a <span>. Combined with the CSS above, every line of text is a <span> at translateY(125%) inside an overflow: hidden .line mask.
2. Counter digit columns (setup)
Build three vertical digit strips (each child is <div class="num">):
.counter-1(hundreds): two nums —"0", then"1"with extra classnum1offset1..counter-2(tens): eleven nums fori = 0..10— text isi, excepti === 10renders
"0"; the i === 1 num gets extra class num1offset2.
.counter-3(units): thirty nums fori = 0..29with texti % 10(0–9 repeated three
times), plus one final "0" num — 31 total.
So the resting state reads "000" and the final state (each column translated to its last num) reads "100".
3. Counter roll tweens (independent gsap.to calls, fired immediately)
Helper animateCounter(counter, duration, delay = 0):
numHeight = counter.querySelector(".num").clientHeighttotalDistance = (numberOfNums - 1) * numHeightgsap.to(counter, { y: -totalDistance, duration, delay, ease: "power2.inOut" })
Calls, in this order:
.counter-3→ duration 2.5, no delay.counter-2→ duration 3, no delay.counter-1→ duration 2, delay 1.5
All three columns land simultaneously at ~3.5s, reading 100.
4. Main timeline (const tl = gsap.timeline())
Before the timeline: gsap.set(".img", { scale: 0 }).
tl.to(".hero-bg", { scaleY: "100%", duration: 3, ease: "power2.inOut", delay: 0.25 })
— the darker panel wipes up from the bottom (transform-origin bottom).
tl.to(".img", { scale: 1, duration: 1, stagger: 0.125, ease: "power3.out" }, "<")— at
the same time, the 15 stacked thumbnails pop in one after another (15 × 0.125 ≈ 1.75s of stagger + 1s tween).
- `tl.to(".counter", { opacity: 0, duration: 0.3, ease: "power3.out", delay: 0.3,
onStart: () => animateImages() }) — after the wipe finishes (plus 0.3s), the counter fades out and the Flip sequence (below) is kicked off via onStart. Note animateImages() runs as its own timeline, not nested in tl`, so the following steps overlap it.
- `tl.to(".sidebar .divider", { scaleY: "100%", duration: 1, ease: "power3.inOut",
delay: 1.25 })` — vertical sidebar line draws downward.
- `tl.to(["nav .divider", ".site-info .divider"], { scaleX: "100%", duration: 1,
stagger: 0.5, ease: "power3.inOut" }, "<")` — horizontal lines draw from the left, 0.5s apart.
tl.to(".logo", { scale: 1, duration: 1, ease: "power4.inOut" }, "<")— sidebar logo mark
scales 0 → 1.
- `tl.to([".logo-name a span", ".links a span, .links p span", ".cta a span"],
{ y: "0%", duration: 1, stagger: 0.1, ease: "power4.out", delay: 0.5 }, "<") — nav text lines rise out of their masks (from the CSS translateY(125%) to 0%`).
- `tl.to([".header span", ".site-info span", ".hero-footer span"],
{ y: "0%", duration: 1, stagger: 0.1, ease: "power4.out" }, "<") — heading, side info and footer lines rise, starting together with step 7 (both positioned at "<"`).
5. animateImages() — the Flip move (star of the show)
const images = document.querySelectorAll(".img");
images.forEach((img) => img.classList.remove("animate-out"));
const state = Flip.getState(images);
images.forEach((img) => img.classList.add("animate-out"));
const mainTimeline = gsap.timeline();
mainTimeline.add(
Flip.from(state, { duration: 1, stagger: 0.1, ease: "power3.inOut" })
);
Then, for each image (index i), build a nested scale-punch timeline and add it to mainTimeline at position i * 0.1 (matching the Flip stagger so each punch rides its own flight):
.to(img, { scale: 2.5, duration: 0.45, ease: "power3.in" }, 0.025)— grows while leaving
the corner,
.to(img, { scale: 1, duration: 0.45, ease: "power3.out" }, 0.5)— shrinks back as it lands
bottom-right.
Net effect: images peel off the top-left stack one every 0.1s, each swelling to 2.5× at the midpoint of its 1s diagonal Flip journey and settling at 1× on the bottom-right stack.
Assets / images
- 15 photographic thumbnails, aspect ratio 5:3 (landscape), varied editorial/portfolio
photography — they render as small rounded-corner cards (~20% viewport width). Cropping is fine (object-fit: cover).
- 1 small square logo mark (1:1, ~32px render size), simple dark abstract glyph on
transparent or matching background, used in the left sidebar.
Use neutral placeholder text/branding — the demo studio name is "Omno".
Behavior notes
- Runs exactly once per page load; no user input required and no scroll interaction.
- Total sequence ≈ 5.5–6s from load to fully revealed layout.
- Responsive: below
1000pxwide —h1drops to2.5rem(letter-spacing-0.05rem),h2
to 1.5rem, nav .links is hidden, .img widens to 30%, .header moves to top: 25% with width: calc(100% - 12.5rem), and .site-info spans calc(100% - 12.5rem) anchored left: 7.5rem instead of right.
- The counter digits rely on
clientHeightof a rendered.num, so build the columns before
starting the tweens (same tick is fine — the elements just need to be in the DOM).
- No reduced-motion handling is required; keep the hero
overflow: hiddenso flipped images
never spill.