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: "Hanken Grotesk" (Google Fonts, import via @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap");), 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.
Images
This component ships with 16 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/konpo-lp-reveal/img1.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img10.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img11.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img12.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img13.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img14.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img15.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img2.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img3.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img4.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img5.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img6.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img7.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img8.jpg
https://motionprompts.dev/c/konpo-lp-reveal/img9.jpg
https://motionprompts.dev/c/konpo-lp-reveal/logo.png
That is all 16: img1.jpg … img15.jpg plus logo.png. The logo.png is the one you cannot guess from the numbering — it is the mark in the header, not one of the gallery plates.
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,--fg,--muted,--accent,--loader-bg,--stroke. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Run this component's setup twice against the same DOM and the failure is concrete, not hypothetical: createCounterDigits appends a fresh set of .num children onto .counter-1, .counter-2 and .counter-3 with no check for what's already there, so the second pass leaves .counter-1 with four digits instead of two — animateCounter's distance math (numHeight times one less than the child count) no longer matches the strip's real length, and the roll lands past or short of "100". Separately, every h1, h2, p, a on the page gets split by SplitText twice, so the second pass wraps .line spans that are already wrapped, and the manual step that injects an inner <span> around each line's text runs one level deeper than the CSS mask expects — the affected lines stop rising into view at all. Neither failure 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 waits for DOMContentLoaded with no readyState guard. By the time a React component mounts, that event has already fired, so the listener is never called and nothing here — not setupTextSplitting, not createCounterDigits, not the three animateCounter calls, not the tl timeline — ever runs. No error, no animation, nothing to debug. Delete the listener and move its entire body directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — setupTextSplitting walks every h1, h2, p, a in the document; createCounterDigits and animateCounter reach for .counter-1, .counter-2, .counter-3 by class; the main timeline and animateImages target .hero-bg, .img, .counter, .sidebar .divider, nav .divider, .site-info .divider, .logo, and the nav/header/site-info/footer spans — all global lookups, all assuming this hero is the only one in the document. Put a ref on the outer <section class="hero"> and resolve every one of these from it instead. This matters most for the broad h1, h2, p, a sweep: during the StrictMode remount two copies of the hero exist for an instant, and an unscoped selector will split text belonging to the copy that's already on its way out.
*(3) Cleanup* — Wrap the setup in one gsap.context scoped to the root ref:
useEffect(() => {
const splits = [];
const ctx = gsap.context((self) => {
// setupTextSplitting: push each SplitText.create(...) return value into `splits`
// createCounterDigits, the three animateCounter tweens, and the tl timeline,
// exactly as constructed above — animateImages(), invoked from tl's onStart,
// should hand its Flip and scale timelines to self.add(...) rather than a bare
// gsap.timeline(), since that callback fires later, well after this synchronous
// pass through the factory has already returned
}, rootRef);
return () => {
ctx.revert();
splits.forEach((split) => split.revert());
rootRef.current
.querySelectorAll(".counter-1, .counter-2, .counter-3")
.forEach((counter) => counter.replaceChildren());
};
}, []);
ctx.revert() picks up the three independent animateCounter tweens — they're never added to tl, but they're still gsap.to() calls made inside the factory, so the context tracks them the same way it tracks the timeline — plus tl itself and whatever animateImages() hands to self.add(). It does not pick up two things this script does by hand, and the order below is not optional for either:
- The
SplitTextinstances.setupTextSplittingcallsSplitText.createin a loop over every matched element without keeping the return value, so as written there is nothing to revert. Collect each instance into an array as you create it, and revert every one afterctx.revert(), never before:tl's last two steps tween.header span,.site-info span,.hero-footer spanand the nav/CTA spans directly — exactly the inner<span>elementssetupTextSplittingwraps each line's text in. Revert the split first and one of those tweens is now targeting a node the revert has already replaced; the kill thatctx.revert()performs on that same tween then throws instead of cleaning up quietly. - The counter digit elements.
createCounterDigitsbuilds every.numchild with plaindocument.createElement/appendChild— GSAP never touches them, so the context never tracks them. Clear.counter-1,.counter-2and.counter-3explicitly in the same cleanup, or the next mount'screateCounterDigitsruns against strips that still have their old digits and doubles them, exactly as described above.
gsap.registerPlugin(Flip, SplitText) stays at module scope, outside the component — re-registering it on every mount is harmless but pointless.
One dependency worth closing before this ships: setupTextSplitting splits by line, and a line box is only measured correctly once "Hanken Grotesk" — pulled in here through the @import at the top of the stylesheet — has actually swapped in, which matters most on the giant h1. useEffect fires as soon as the DOM commits, almost certainly before a webfont load has settled, so splitting immediately measures against the fallback face and the mask-and-rise reveal plays against line breaks that don't match the text once the real font arrives. Gate setupTextSplitting — and, since tl's last two steps tween the spans that split produces on .header, .site-info and .hero-footer, the construction of tl along with it — behind document.fonts.ready, keep the effect itself synchronous, and flip a cancellation flag in the cleanup so a StrictMode unmount that lands before the font resolves doesn't run SplitText.create against a heading that's already gone.