Looping preview · motionprompts.dev
The build prompt
From motionprompts.dev20,704 chars
# 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**:
```js
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):
```html
<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.1`
- `h2`: `1.75rem`, `letter-spacing: -0.02rem`, `line-height: 1.1`
- `a, 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 `.divider`s share `background-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 class `num1offset1`.
- `.counter-2` (tens): eleven nums for `i = 0..10` — text is `i`, except `i === 10` renders
`"0"`; the `i === 1` num gets extra class `num1offset2`.
- `.counter-3` (units): thirty nums for `i = 0..29` with text `i % 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").clientHeight`
- `totalDistance = (numberOfNums - 1) * numHeight`
- `gsap.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 })`.
1. `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).
2. `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).
3. `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.
4. `tl.to(".sidebar .divider", { scaleY: "100%", duration: 1, ease: "power3.inOut",
delay: 1.25 })` — vertical sidebar line draws downward.
5. `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.
6. `tl.to(".logo", { scale: 1, duration: 1, ease: "power4.inOut" }, "<")` — sidebar logo mark
scales 0 → 1.
7. `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%`).
8. `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 `1000px` wide — `h1` drops to `2.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 `clientHeight` of 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: hidden` so 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: `--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.
## 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 `span`s — 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:
```jsx
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 `SplitText` instances.** `setupTextSplitting` calls `SplitText.create` in 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 **after** `ctx.revert()`, never before: `tl`'s last two steps tween `.header span`, `.site-info span`, `.hero-footer span` and the nav/CTA spans directly — exactly the inner `<span>` elements `setupTextSplitting` wraps 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 that `ctx.revert()` performs on that same tween then throws instead of cleaning up quietly.
- **The counter digit elements.** `createCounterDigits` builds every `.num` child with plain `document.createElement`/`appendChild` — GSAP never touches them, so the context never tracks them. Clear `.counter-1`, `.counter-2` and `.counter-3` explicitly in the same cleanup, or the next mount's `createCounterDigits` runs 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.
