Counter Preloader with Split Reveal
A full-screen preloader counts 0→100 in a huge serif numeral while a hairline fills; the number and label slide out of their masks, two panels split open and the hero headline rises as its image settles from 1.15 to 1.
- Preloaders
- gsap
- splittext
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1387 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Counter Preloader with Split Reveal
**What it does (visual):** The page opens on a solid dark screen. Centred is a huge light-weight serif numeral (about 24vw tall) reading "0", with the mono word "LOADING" letter-spaced beneath it and a 1px hairline in `#d4ff3a` sitting 2.5rem above the bottom edge, spanning from 6vw to 94vw but scaled to zero width. Over 2.2 seconds the number counts up to 100 (integer steps, tabular digits so nothing jitters) while the hairline grows from the left to full width on the exact same ease — slow start, quick middle, slow landing. The moment the count lands, "LOADING" slides upward out of its clipping mask (0.6s, accelerating), then the numeral follows it up out of its own mask (0.7s), and the hairline retracts into its right end. A beat later the screen splits horizontally: the top half of the dark screen slides up off-screen and the bottom half slides down, both over 1.4s with a long held start and a fast finish (`expo.inOut`). Behind the opening panels a hero is already alive: a 4:5 image on the right settles from 1.15× to 100% scale over 1.6s, and the two-line serif headline on the left rises character by character (0.02s apart) out of a masked baseline. A mono eyebrow and a short paragraph fade and rise next, then a small outlined "Replay preloader" button appears. Clicking Replay rewinds and plays the entire sequence again.
**Tech stack:**
- GSAP 3.13+ core and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- Fonts: a variable serif for the numeral and headline (demo: Fraunces 300, `opsz 144`), a mono for labels, a sans for body copy.
**HTML structure:**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
(The demo paints `.cpr__img` with layered CSS gradients plus an SVG-noise grain; use an `<img>` or `background-image` in production.)
**CSS requirements:**
- `.cpr__loader`: `position: fixed; inset: 0; z-index: 10; pointer-events: none;`
- `.cpr__panel`: `position: absolute; left: 0; right: 0; height: 50%; background: var(--bg, #0a0a0a); will-change: transform;` — `--top` has `top: 0` and a `1px solid var(--line)` bottom border (reads as a seam when the split begins); `--bottom` has `bottom: 0`.
- `.cpr__center`: `position: absolute; inset: 0; display: grid; place-content: center; text-align: center;` (above the panels in source order, so it paints over them).
- `.cpr__mask`: `overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em;` (room for descenders).
- `.cpr__count`: display serif, `font-weight: 300; font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em; font-variant-numeric: tabular-nums; display: block;`
- `.cpr__label`: mono, `11px`, uppercase, `letter-spacing: .2em`, `color: var(--muted)`, mask has `margin-top: 1.75rem`.
- `.cpr__line`: `position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0);`
- `.cpr__hero`: `min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw;`
- `.cpr__title`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; visibility: hidden;` (JS sets `autoAlpha: 1` once split). `.line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em }`, `.char { display: inline-block; will-change: transform }`.
- `.cpr__eyebrow` (mono 11px uppercase, muted) and `.cpr__sub` (muted, `max-width: 42ch`, `line-height: 1.5`) start at `opacity: 0`.
- `.cpr__media`: `aspect-ratio: 4/5; max-height: 78vh; width: 100%; overflow: hidden; border-radius: 6px;` — `.cpr__img { position: absolute; inset: 0; transform: scale(1.15); will-change: transform; }` (the CSS start scale matches the tween so nothing jumps before JS runs).
- `.cpr__replay`: outlined pill (`border: 1px solid` 25% text colour, `border-radius: 999px; padding: .7em 1.4em`), `opacity: 0`, inverts on hover, visible focus ring in `--accent`.
- Under 900px: `.cpr__hero { grid-template-columns: 1fr }`, media first (`order: -1`, `aspect-ratio: 4/3; max-height: 48vh`).
**Animation spec (exact values):**
Let `C = 2.2` (the count duration — every later position is relative to it, so changing it re-times the whole sequence). Wait for `document.fonts.ready`, then `SplitText.create('.cpr__title', { type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto', onSplit: (self) => build(self.chars) })`. Inside `build` create ONE master timeline, `tl = gsap.timeline({ defaults: { ease: 'power3.in' } })` and `const counter = { v: 0 }`:
1. `tl.set('.cpr__loader', { autoAlpha: 1 }).set(['.cpr__panel--top', '.cpr__panel--bottom', '.cpr__count', '.cpr__label'], { yPercent: 0 }).set('.cpr__line', { scaleX: 0, transformOrigin: 'left center' }).set('.cpr__title', { autoAlpha: 1 })` — resets so `restart()` works.
2. Counter — `tl.fromTo(counter, { v: 0 }, { v: 100, duration: 2.2, ease: 'power2.inOut', snap: { v: 1 }, onUpdate: () => count.textContent = String(counter.v) }, 0)`.
3. Hairline grows — `tl.to('.cpr__line', { scaleX: 1, duration: 2.2, ease: 'power2.inOut' }, 0)` (identical curve to the counter).
4. Label exits — `tl.to('.cpr__label', { yPercent: -120, duration: 0.6, ease: 'power3.in' }, C + 0.05)`.
5. Numeral exits — `tl.to('.cpr__count', { yPercent: -110, duration: 0.7, ease: 'power3.in' }, C + 0.15)`.
6. Hairline retracts — `tl.to('.cpr__line', { scaleX: 0, transformOrigin: 'right center', duration: 0.6, ease: 'power3.in' }, C + 0.2)`.
7. Panels split — `tl.to('.cpr__panel--top', { yPercent: -100, duration: 1.4, ease: 'expo.inOut' }, C + 0.7)` and `tl.to('.cpr__panel--bottom', { yPercent: 100, duration: 1.4, ease: 'expo.inOut' }, '<')`.
8. Image settles — `tl.fromTo('.cpr__img', { scale: 1.15 }, { scale: 1, duration: 1.6, ease: 'expo.out' }, C + 1.2)`.
9. Headline chars — `tl.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger: 0.02 }, C + 1.3)`.
10. Eyebrow + paragraph — `tl.fromTo(['.cpr__eyebrow', '.cpr__sub'], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, C + 1.8)`.
11. Replay button — `tl.fromTo('.cpr__replay', { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, C + 2.1)`.
12. `tl.set('.cpr__loader', { autoAlpha: 0 })` at the end so the hidden loader cannot intercept anything.
Return `tl` from `onSplit` so SplitText keeps progress on re-split. Total ≈ C + 2.6s (≈4.8s at the default 2.2).
**Interaction:** No scroll or cursor dependency. `.cpr__replay` click → `tl.restart()` (every tween is `fromTo`/`set`, so state resets cleanly). Hover inverts the button over 0.25s.
**Responsive behavior:** With `gsap.matchMedia()`: `(min-width: 768px)` uses `stagger: 0.02`; `(max-width: 767px)` uses `stagger: 0.02 × 0.7` (more characters per line after wrapping keeps the headline ≈1s). The numeral is `clamp(6rem, 24vw, 19rem)` so it stays inside a 375px screen; the hero stacks under 900px via CSS.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` skip everything: `display: none` the loader, show the title/eyebrow/paragraph at full opacity, `.cpr__img { transform: none }`, hide the Replay button (do it in both CSS `@media` and JS so it works before JS). SplitText `aria: 'auto'` keeps the heading readable. Focus ring: `outline: 2px solid var(--accent); outline-offset: 3px`.
**Performance rules:** Only `transform` and `opacity` animate (yPercent, scaleX, scale, autoAlpha). `will-change: transform` lives on the panels, numeral, label, hairline and image only. Everything is inside `gsap.context()`; `ctx.revert()` on unmount also reverts the split. No `innerText` tweening — write `textContent` from the object counter. The host pauses `gsap.globalTimeline` off-screen; nothing else runs.
**Customization knobs:**
- `COUNT_DURATION` (2.2) — the whole sequence is positioned relative to it.
- `SPLIT_DURATION` (1.4) and its ease (`expo.inOut`; `power4.inOut` gives a softer curtain, `circ.inOut` a snappier one).
- Counter ease `power2.inOut` (use `none` for a mechanical linear count).
- Hero image start scale (1.15) and char `stagger` (0.02).
- Panel colour (`--bg`) and hairline colour (`--accent`: #d4ff3a).
**Acceptance criteria:**
- [ ] Numeral counts 0→100 in integer steps over 2.2s with no horizontal jitter; the hairline reaches full width at exactly the same moment.
- [ ] Label, then numeral, exit upward and are clipped by their masks (never visible above them).
- [ ] Panels split at C + 0.7s with `expo.inOut` over 1.4s; the hero image is already scaling and the headline characters start rising before the panels have left the screen.
- [ ] Replay restarts the full sequence from "0"; resizing re-splits without duplicating characters.
- [ ] Reduced-motion users see the hero immediately and never see the loader.
- [ ] No console errors, no layout shift when the headline is split. Add this effect to my existing site/section: **Counter Preloader with Split Reveal**. Keep my current hero markup and styles; put a `position: fixed; inset: 0; z-index: 10` `.cpr__loader` before it containing two 50%-height `var(--bg)` panels, a centred masked serif counter (`tabular-nums`) with a masked "Loading" label, and a 1px `#d4ff3a` hairline at `bottom: 2.5rem`. With GSAP build one timeline where `C = 2.2`: `{ v: 0→100, duration: C, ease: 'power2.inOut', snap: { v: 1 } }` writing `textContent`, hairline `scaleX 0→1` on the same curve, label `yPercent → -120` (0.6s `power3.in`) at C + 0.05, counter `yPercent → -110` (0.7s) at C + 0.15, hairline `scaleX → 0` from the right at C + 0.2, panels `yPercent -100 / 100` for 1.4s `expo.inOut` at C + 0.7, my hero image `scale 1.15→1` (1.6s `expo.out`) at C + 1.2 and my headline's SplitText chars `yPercent 110→0` (1s `power4.out`, stagger 0.02, `mask: 'lines'`) at C + 1.3, then the supporting copy `y 20→0, opacity 0→1`. Use `fromTo` everywhere so `tl.restart()` works, wrap in `gsap.context()`, hide the loader entirely for `prefers-reduced-motion`, and do not change my fonts or colours. ### EFFECT (React / Next.js): Counter Preloader with Split Reveal
Build **Counter Preloader with Split Reveal** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/CounterPreloaderWithSplitReveal.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, SplitText);
export default function CounterPreloaderWithSplitReveal() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="cpr">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`CounterPreloaderWithSplitReveal.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Counter Preloader with Split Reveal
**What it does (visual):** The page opens on a solid dark screen. Centred is a huge light-weight serif numeral (about 24vw tall) reading "0", with the mono word "LOADING" letter-spaced beneath it and a 1px hairline in `#d4ff3a` sitting 2.5rem above the bottom edge, spanning from 6vw to 94vw but scaled to zero width. Over 2.2 seconds the number counts up to 100 (integer steps, tabular digits so nothing jitters) while the hairline grows from the left to full width on the exact same ease — slow start, quick middle, slow landing. The moment the count lands, "LOADING" slides upward out of its clipping mask (0.6s, accelerating), then the numeral follows it up out of its own mask (0.7s), and the hairline retracts into its right end. A beat later the screen splits horizontally: the top half of the dark screen slides up off-screen and the bottom half slides down, both over 1.4s with a long held start and a fast finish (`expo.inOut`). Behind the opening panels a hero is already alive: a 4:5 image on the right settles from 1.15× to 100% scale over 1.6s, and the two-line serif headline on the left rises character by character (0.02s apart) out of a masked baseline. A mono eyebrow and a short paragraph fade and rise next, then a small outlined "Replay preloader" button appears. Clicking Replay rewinds and plays the entire sequence again.
**Tech stack:**
- GSAP 3.13+ core and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- Fonts: a variable serif for the numeral and headline (demo: Fraunces 300, `opsz 144`), a mono for labels, a sans for body copy.
**HTML structure:**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
(The demo paints `.cpr__img` with layered CSS gradients plus an SVG-noise grain; use an `<img>` or `background-image` in production.)
**CSS requirements:**
- `.cpr__loader`: `position: fixed; inset: 0; z-index: 10; pointer-events: none;`
- `.cpr__panel`: `position: absolute; left: 0; right: 0; height: 50%; background: var(--bg, #0a0a0a); will-change: transform;` — `--top` has `top: 0` and a `1px solid var(--line)` bottom border (reads as a seam when the split begins); `--bottom` has `bottom: 0`.
- `.cpr__center`: `position: absolute; inset: 0; display: grid; place-content: center; text-align: center;` (above the panels in source order, so it paints over them).
- `.cpr__mask`: `overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em;` (room for descenders).
- `.cpr__count`: display serif, `font-weight: 300; font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em; font-variant-numeric: tabular-nums; display: block;`
- `.cpr__label`: mono, `11px`, uppercase, `letter-spacing: .2em`, `color: var(--muted)`, mask has `margin-top: 1.75rem`.
- `.cpr__line`: `position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0);`
- `.cpr__hero`: `min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw;`
- `.cpr__title`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; visibility: hidden;` (JS sets `autoAlpha: 1` once split). `.line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em }`, `.char { display: inline-block; will-change: transform }`.
- `.cpr__eyebrow` (mono 11px uppercase, muted) and `.cpr__sub` (muted, `max-width: 42ch`, `line-height: 1.5`) start at `opacity: 0`.
- `.cpr__media`: `aspect-ratio: 4/5; max-height: 78vh; width: 100%; overflow: hidden; border-radius: 6px;` — `.cpr__img { position: absolute; inset: 0; transform: scale(1.15); will-change: transform; }` (the CSS start scale matches the tween so nothing jumps before JS runs).
- `.cpr__replay`: outlined pill (`border: 1px solid` 25% text colour, `border-radius: 999px; padding: .7em 1.4em`), `opacity: 0`, inverts on hover, visible focus ring in `--accent`.
- Under 900px: `.cpr__hero { grid-template-columns: 1fr }`, media first (`order: -1`, `aspect-ratio: 4/3; max-height: 48vh`).
**Animation spec (exact values):**
Let `C = 2.2` (the count duration — every later position is relative to it, so changing it re-times the whole sequence). Wait for `document.fonts.ready`, then `SplitText.create('.cpr__title', { type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto', onSplit: (self) => build(self.chars) })`. Inside `build` create ONE master timeline, `tl = gsap.timeline({ defaults: { ease: 'power3.in' } })` and `const counter = { v: 0 }`:
1. `tl.set('.cpr__loader', { autoAlpha: 1 }).set(['.cpr__panel--top', '.cpr__panel--bottom', '.cpr__count', '.cpr__label'], { yPercent: 0 }).set('.cpr__line', { scaleX: 0, transformOrigin: 'left center' }).set('.cpr__title', { autoAlpha: 1 })` — resets so `restart()` works.
2. Counter — `tl.fromTo(counter, { v: 0 }, { v: 100, duration: 2.2, ease: 'power2.inOut', snap: { v: 1 }, onUpdate: () => count.textContent = String(counter.v) }, 0)`.
3. Hairline grows — `tl.to('.cpr__line', { scaleX: 1, duration: 2.2, ease: 'power2.inOut' }, 0)` (identical curve to the counter).
4. Label exits — `tl.to('.cpr__label', { yPercent: -120, duration: 0.6, ease: 'power3.in' }, C + 0.05)`.
5. Numeral exits — `tl.to('.cpr__count', { yPercent: -110, duration: 0.7, ease: 'power3.in' }, C + 0.15)`.
6. Hairline retracts — `tl.to('.cpr__line', { scaleX: 0, transformOrigin: 'right center', duration: 0.6, ease: 'power3.in' }, C + 0.2)`.
7. Panels split — `tl.to('.cpr__panel--top', { yPercent: -100, duration: 1.4, ease: 'expo.inOut' }, C + 0.7)` and `tl.to('.cpr__panel--bottom', { yPercent: 100, duration: 1.4, ease: 'expo.inOut' }, '<')`.
8. Image settles — `tl.fromTo('.cpr__img', { scale: 1.15 }, { scale: 1, duration: 1.6, ease: 'expo.out' }, C + 1.2)`.
9. Headline chars — `tl.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger: 0.02 }, C + 1.3)`.
10. Eyebrow + paragraph — `tl.fromTo(['.cpr__eyebrow', '.cpr__sub'], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, C + 1.8)`.
11. Replay button — `tl.fromTo('.cpr__replay', { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, C + 2.1)`.
12. `tl.set('.cpr__loader', { autoAlpha: 0 })` at the end so the hidden loader cannot intercept anything.
Return `tl` from `onSplit` so SplitText keeps progress on re-split. Total ≈ C + 2.6s (≈4.8s at the default 2.2).
**Interaction:** No scroll or cursor dependency. `.cpr__replay` click → `tl.restart()` (every tween is `fromTo`/`set`, so state resets cleanly). Hover inverts the button over 0.25s.
**Responsive behavior:** With `gsap.matchMedia()`: `(min-width: 768px)` uses `stagger: 0.02`; `(max-width: 767px)` uses `stagger: 0.02 × 0.7` (more characters per line after wrapping keeps the headline ≈1s). The numeral is `clamp(6rem, 24vw, 19rem)` so it stays inside a 375px screen; the hero stacks under 900px via CSS.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` skip everything: `display: none` the loader, show the title/eyebrow/paragraph at full opacity, `.cpr__img { transform: none }`, hide the Replay button (do it in both CSS `@media` and JS so it works before JS). SplitText `aria: 'auto'` keeps the heading readable. Focus ring: `outline: 2px solid var(--accent); outline-offset: 3px`.
**Performance rules:** Only `transform` and `opacity` animate (yPercent, scaleX, scale, autoAlpha). `will-change: transform` lives on the panels, numeral, label, hairline and image only. Everything is inside `gsap.context()`; `ctx.revert()` on unmount also reverts the split. No `innerText` tweening — write `textContent` from the object counter. The host pauses `gsap.globalTimeline` off-screen; nothing else runs.
**Customization knobs:**
- `COUNT_DURATION` (2.2) — the whole sequence is positioned relative to it.
- `SPLIT_DURATION` (1.4) and its ease (`expo.inOut`; `power4.inOut` gives a softer curtain, `circ.inOut` a snappier one).
- Counter ease `power2.inOut` (use `none` for a mechanical linear count).
- Hero image start scale (1.15) and char `stagger` (0.02).
- Panel colour (`--bg`) and hairline colour (`--accent`: #d4ff3a).
**Acceptance criteria:**
- [ ] Numeral counts 0→100 in integer steps over 2.2s with no horizontal jitter; the hairline reaches full width at exactly the same moment.
- [ ] Label, then numeral, exit upward and are clipped by their masks (never visible above them).
- [ ] Panels split at C + 0.7s with `expo.inOut` over 1.4s; the hero image is already scaling and the headline characters start rising before the panels have left the screen.
- [ ] Replay restarts the full sequence from "0"; resizing re-splits without duplicating characters.
- [ ] Reduced-motion users see the hero immediately and never see the loader.
- [ ] No console errors, no layout shift when the headline is split.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass that turns the valley into the fourth room.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
```css
.cpr { --cpr-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.cpr__loader { position: fixed; inset: 0; z-index: 10; pointer-events: none; }
.cpr__panel { position: absolute; left: 0; right: 0; height: 50%; background: var(--bg); will-change: transform; }
.cpr__panel--top { top: 0; border-bottom: 1px solid var(--line); }
.cpr__panel--bottom { bottom: 0; }
.cpr__center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.cpr__mask { overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em; }
.cpr__count {
display: block; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em;
font-variant-numeric: tabular-nums; color: var(--text); will-change: transform;
}
.cpr__mask--label { margin-top: 1.75rem; }
.cpr__label { display: block; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .2em; color: var(--muted); will-change: transform; }
.cpr__line { position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--cpr-accent); transform-origin: left; transform: scaleX(0); will-change: transform; }
/* ---------- hero ---------- */
.cpr__hero {
position: relative; min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center;
gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw; background: var(--bg); color: var(--text);
}
.cpr__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); opacity: 0; }
.cpr__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; margin: .6em 0 .5em;
visibility: hidden; text-wrap: balance;
}
.cpr__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.cpr__title .char { display: inline-block; will-change: transform; }
.cpr__sub { max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: 0; }
.cpr__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.cpr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.cpr__replay:focus-visible { outline: 2px solid var(--cpr-accent); outline-offset: 3px; }
.cpr__media { position: relative; aspect-ratio: 4 / 5; max-height: 78vh; justify-self: end; width: 100%; overflow: hidden; border-radius: 6px; background: var(--surface); }
.cpr__img {
position: absolute; inset: 0; transform: scale(var(--knob-image-scale, 1.15)); will-change: transform;
background:
radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 28%, transparent), transparent 70%),
radial-gradient(70% 55% at 18% 86%, #30455f, transparent 72%),
linear-gradient(165deg, #22272f 0%, #0b0c0e 100%);
}
.cpr__img::before { /* the "wall of glass": a soft vertical light column */
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 56%, rgba(255,255,255,.07) 58%, rgba(255,255,255,.02) 74%, transparent 76%);
}
.cpr__img::after { /* film grain */
content: ''; position: absolute; inset: 0; opacity: .32; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
:root[data-theme="light"] .cpr__img { background: radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 30%, transparent), transparent 70%), radial-gradient(70% 55% at 18% 86%, #9fb3c8, transparent 72%), linear-gradient(165deg, #e6e2da 0%, #b8b2a6 100%); }
@media (max-width: 899px) {
.cpr__hero { grid-template-columns: 1fr; align-content: center; gap: 2rem; padding-block: 5rem 3rem; }
.cpr__media { max-height: 48vh; aspect-ratio: 4 / 3; order: -1; justify-self: stretch; }
.cpr__title { margin-top: .3em; }
.cpr__replay { margin-top: 1.75rem; }
}
/* reduced motion: no preloader, hero simply present */
@media (prefers-reduced-motion: reduce) {
.cpr__loader { display: none; }
.cpr__title { visibility: visible; }
.cpr__eyebrow, .cpr__sub { opacity: 1; }
.cpr__img { transform: none; }
.cpr__replay { display: none; }
}
```
```js
// Counter Preloader with Split Reveal — 0→100 serif counter, masked exits, panels split, hero rises.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { countDuration: 2.2, splitDuration: 1.4, splitEase: 'expo.inOut', imageScale: 1.15, charStagger: 0.02, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.cpr');
const loader = root.querySelector('.cpr__loader');
const panelTop = root.querySelector('.cpr__panel--top');
const panelBottom = root.querySelector('.cpr__panel--bottom');
const count = root.querySelector('.cpr__count');
const label = root.querySelector('.cpr__label');
const line = root.querySelector('.cpr__line');
const title = root.querySelector('.cpr__title');
const eyebrow = root.querySelector('.cpr__eyebrow');
const sub = root.querySelector('.cpr__sub');
const img = root.querySelector('.cpr__img');
const replay = root.querySelector('.cpr__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-image-scale', String(K('imageScale')));
const COUNT_DURATION = K('countDuration'); // seconds for 0 → 100
const SPLIT_DURATION = K('splitDuration'); // panel split
const splitEase = K('splitEase');
const imageScale = K('imageScale');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set([title, eyebrow, sub], { autoAlpha: 1 });
gsap.set(img, { scale: 1 });
return;
}
const counter = { v: 0 };
let tl;
const make = (chars, stagger) => {
tl = gsap.timeline({ defaults: { ease: 'power3.in' } });
tl.set(loader, { autoAlpha: 1 })
.set([panelTop, panelBottom, count, label], { yPercent: 0 })
.set(line, { scaleX: 0, transformOrigin: 'left center' })
.set(title, { autoAlpha: 1 })
// 1. count + line share one curve
.fromTo(counter, { v: 0 }, {
v: 100, duration: COUNT_DURATION, ease: 'power2.inOut', snap: { v: 1 },
onUpdate: () => { count.textContent = String(counter.v); },
}, 0)
.to(line, { scaleX: 1, duration: COUNT_DURATION, ease: 'power2.inOut' }, 0)
// 2. masked exits (accelerate away)
.to(label, { yPercent: -120, duration: 0.6 }, COUNT_DURATION + 0.05)
.to(count, { yPercent: -110, duration: 0.7 }, COUNT_DURATION + 0.15)
.to(line, { scaleX: 0, transformOrigin: 'right center', duration: 0.6 }, COUNT_DURATION + 0.2)
// 3. curtains
.to(panelTop, { yPercent: -100, duration: SPLIT_DURATION, ease: splitEase }, COUNT_DURATION + 0.7)
.to(panelBottom, { yPercent: 100, duration: SPLIT_DURATION, ease: splitEase }, '<')
// 4. hero (starts while the panels are still moving)
.fromTo(img, { scale: imageScale }, { scale: 1, duration: 1.6, ease: 'expo.out' }, COUNT_DURATION + 1.2)
.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger }, COUNT_DURATION + 1.3)
.fromTo([eyebrow, sub], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, COUNT_DURATION + 1.8)
.fromTo(replay, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, COUNT_DURATION + 2.1)
.set(loader, { autoAlpha: 0 });
return tl;
};
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const stagger = mmCtx.conditions.mobile ? K('charStagger') * 0.7 : K('charStagger');
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit: (self) => make(self.chars, stagger),
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });
```
### EFFECT (single HTML file): Counter Preloader with Split Reveal
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Counter Preloader with Split Reveal** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Counter Preloader with Split Reveal</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Counter Preloader with Split Reveal
**What it does (visual):** The page opens on a solid dark screen. Centred is a huge light-weight serif numeral (about 24vw tall) reading "0", with the mono word "LOADING" letter-spaced beneath it and a 1px hairline in `#d4ff3a` sitting 2.5rem above the bottom edge, spanning from 6vw to 94vw but scaled to zero width. Over 2.2 seconds the number counts up to 100 (integer steps, tabular digits so nothing jitters) while the hairline grows from the left to full width on the exact same ease — slow start, quick middle, slow landing. The moment the count lands, "LOADING" slides upward out of its clipping mask (0.6s, accelerating), then the numeral follows it up out of its own mask (0.7s), and the hairline retracts into its right end. A beat later the screen splits horizontally: the top half of the dark screen slides up off-screen and the bottom half slides down, both over 1.4s with a long held start and a fast finish (`expo.inOut`). Behind the opening panels a hero is already alive: a 4:5 image on the right settles from 1.15× to 100% scale over 1.6s, and the two-line serif headline on the left rises character by character (0.02s apart) out of a masked baseline. A mono eyebrow and a short paragraph fade and rise next, then a small outlined "Replay preloader" button appears. Clicking Replay rewinds and plays the entire sequence again.
**Tech stack:**
- GSAP 3.13+ core and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- Fonts: a variable serif for the numeral and headline (demo: Fraunces 300, `opsz 144`), a mono for labels, a sans for body copy.
**HTML structure:**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
(The demo paints `.cpr__img` with layered CSS gradients plus an SVG-noise grain; use an `<img>` or `background-image` in production.)
**CSS requirements:**
- `.cpr__loader`: `position: fixed; inset: 0; z-index: 10; pointer-events: none;`
- `.cpr__panel`: `position: absolute; left: 0; right: 0; height: 50%; background: var(--bg, #0a0a0a); will-change: transform;` — `--top` has `top: 0` and a `1px solid var(--line)` bottom border (reads as a seam when the split begins); `--bottom` has `bottom: 0`.
- `.cpr__center`: `position: absolute; inset: 0; display: grid; place-content: center; text-align: center;` (above the panels in source order, so it paints over them).
- `.cpr__mask`: `overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em;` (room for descenders).
- `.cpr__count`: display serif, `font-weight: 300; font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em; font-variant-numeric: tabular-nums; display: block;`
- `.cpr__label`: mono, `11px`, uppercase, `letter-spacing: .2em`, `color: var(--muted)`, mask has `margin-top: 1.75rem`.
- `.cpr__line`: `position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0);`
- `.cpr__hero`: `min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw;`
- `.cpr__title`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; visibility: hidden;` (JS sets `autoAlpha: 1` once split). `.line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em }`, `.char { display: inline-block; will-change: transform }`.
- `.cpr__eyebrow` (mono 11px uppercase, muted) and `.cpr__sub` (muted, `max-width: 42ch`, `line-height: 1.5`) start at `opacity: 0`.
- `.cpr__media`: `aspect-ratio: 4/5; max-height: 78vh; width: 100%; overflow: hidden; border-radius: 6px;` — `.cpr__img { position: absolute; inset: 0; transform: scale(1.15); will-change: transform; }` (the CSS start scale matches the tween so nothing jumps before JS runs).
- `.cpr__replay`: outlined pill (`border: 1px solid` 25% text colour, `border-radius: 999px; padding: .7em 1.4em`), `opacity: 0`, inverts on hover, visible focus ring in `--accent`.
- Under 900px: `.cpr__hero { grid-template-columns: 1fr }`, media first (`order: -1`, `aspect-ratio: 4/3; max-height: 48vh`).
**Animation spec (exact values):**
Let `C = 2.2` (the count duration — every later position is relative to it, so changing it re-times the whole sequence). Wait for `document.fonts.ready`, then `SplitText.create('.cpr__title', { type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto', onSplit: (self) => build(self.chars) })`. Inside `build` create ONE master timeline, `tl = gsap.timeline({ defaults: { ease: 'power3.in' } })` and `const counter = { v: 0 }`:
1. `tl.set('.cpr__loader', { autoAlpha: 1 }).set(['.cpr__panel--top', '.cpr__panel--bottom', '.cpr__count', '.cpr__label'], { yPercent: 0 }).set('.cpr__line', { scaleX: 0, transformOrigin: 'left center' }).set('.cpr__title', { autoAlpha: 1 })` — resets so `restart()` works.
2. Counter — `tl.fromTo(counter, { v: 0 }, { v: 100, duration: 2.2, ease: 'power2.inOut', snap: { v: 1 }, onUpdate: () => count.textContent = String(counter.v) }, 0)`.
3. Hairline grows — `tl.to('.cpr__line', { scaleX: 1, duration: 2.2, ease: 'power2.inOut' }, 0)` (identical curve to the counter).
4. Label exits — `tl.to('.cpr__label', { yPercent: -120, duration: 0.6, ease: 'power3.in' }, C + 0.05)`.
5. Numeral exits — `tl.to('.cpr__count', { yPercent: -110, duration: 0.7, ease: 'power3.in' }, C + 0.15)`.
6. Hairline retracts — `tl.to('.cpr__line', { scaleX: 0, transformOrigin: 'right center', duration: 0.6, ease: 'power3.in' }, C + 0.2)`.
7. Panels split — `tl.to('.cpr__panel--top', { yPercent: -100, duration: 1.4, ease: 'expo.inOut' }, C + 0.7)` and `tl.to('.cpr__panel--bottom', { yPercent: 100, duration: 1.4, ease: 'expo.inOut' }, '<')`.
8. Image settles — `tl.fromTo('.cpr__img', { scale: 1.15 }, { scale: 1, duration: 1.6, ease: 'expo.out' }, C + 1.2)`.
9. Headline chars — `tl.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger: 0.02 }, C + 1.3)`.
10. Eyebrow + paragraph — `tl.fromTo(['.cpr__eyebrow', '.cpr__sub'], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, C + 1.8)`.
11. Replay button — `tl.fromTo('.cpr__replay', { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, C + 2.1)`.
12. `tl.set('.cpr__loader', { autoAlpha: 0 })` at the end so the hidden loader cannot intercept anything.
Return `tl` from `onSplit` so SplitText keeps progress on re-split. Total ≈ C + 2.6s (≈4.8s at the default 2.2).
**Interaction:** No scroll or cursor dependency. `.cpr__replay` click → `tl.restart()` (every tween is `fromTo`/`set`, so state resets cleanly). Hover inverts the button over 0.25s.
**Responsive behavior:** With `gsap.matchMedia()`: `(min-width: 768px)` uses `stagger: 0.02`; `(max-width: 767px)` uses `stagger: 0.02 × 0.7` (more characters per line after wrapping keeps the headline ≈1s). The numeral is `clamp(6rem, 24vw, 19rem)` so it stays inside a 375px screen; the hero stacks under 900px via CSS.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` skip everything: `display: none` the loader, show the title/eyebrow/paragraph at full opacity, `.cpr__img { transform: none }`, hide the Replay button (do it in both CSS `@media` and JS so it works before JS). SplitText `aria: 'auto'` keeps the heading readable. Focus ring: `outline: 2px solid var(--accent); outline-offset: 3px`.
**Performance rules:** Only `transform` and `opacity` animate (yPercent, scaleX, scale, autoAlpha). `will-change: transform` lives on the panels, numeral, label, hairline and image only. Everything is inside `gsap.context()`; `ctx.revert()` on unmount also reverts the split. No `innerText` tweening — write `textContent` from the object counter. The host pauses `gsap.globalTimeline` off-screen; nothing else runs.
**Customization knobs:**
- `COUNT_DURATION` (2.2) — the whole sequence is positioned relative to it.
- `SPLIT_DURATION` (1.4) and its ease (`expo.inOut`; `power4.inOut` gives a softer curtain, `circ.inOut` a snappier one).
- Counter ease `power2.inOut` (use `none` for a mechanical linear count).
- Hero image start scale (1.15) and char `stagger` (0.02).
- Panel colour (`--bg`) and hairline colour (`--accent`: #d4ff3a).
**Acceptance criteria:**
- [ ] Numeral counts 0→100 in integer steps over 2.2s with no horizontal jitter; the hairline reaches full width at exactly the same moment.
- [ ] Label, then numeral, exit upward and are clipped by their masks (never visible above them).
- [ ] Panels split at C + 0.7s with `expo.inOut` over 1.4s; the hero image is already scaling and the headline characters start rising before the panels have left the screen.
- [ ] Replay restarts the full sequence from "0"; resizing re-splits without duplicating characters.
- [ ] Reduced-motion users see the hero immediately and never see the loader.
- [ ] No console errors, no layout shift when the headline is split.
---
**Reference implementation (use these exact values and class names):**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass that turns the valley into the fourth room.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
```css
.cpr { --cpr-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.cpr__loader { position: fixed; inset: 0; z-index: 10; pointer-events: none; }
.cpr__panel { position: absolute; left: 0; right: 0; height: 50%; background: var(--bg); will-change: transform; }
.cpr__panel--top { top: 0; border-bottom: 1px solid var(--line); }
.cpr__panel--bottom { bottom: 0; }
.cpr__center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.cpr__mask { overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em; }
.cpr__count {
display: block; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em;
font-variant-numeric: tabular-nums; color: var(--text); will-change: transform;
}
.cpr__mask--label { margin-top: 1.75rem; }
.cpr__label { display: block; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .2em; color: var(--muted); will-change: transform; }
.cpr__line { position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--cpr-accent); transform-origin: left; transform: scaleX(0); will-change: transform; }
/* ---------- hero ---------- */
.cpr__hero {
position: relative; min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center;
gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw; background: var(--bg); color: var(--text);
}
.cpr__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); opacity: 0; }
.cpr__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; margin: .6em 0 .5em;
visibility: hidden; text-wrap: balance;
}
.cpr__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.cpr__title .char { display: inline-block; will-change: transform; }
.cpr__sub { max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: 0; }
.cpr__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.cpr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.cpr__replay:focus-visible { outline: 2px solid var(--cpr-accent); outline-offset: 3px; }
.cpr__media { position: relative; aspect-ratio: 4 / 5; max-height: 78vh; justify-self: end; width: 100%; overflow: hidden; border-radius: 6px; background: var(--surface); }
.cpr__img {
position: absolute; inset: 0; transform: scale(var(--knob-image-scale, 1.15)); will-change: transform;
background:
radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 28%, transparent), transparent 70%),
radial-gradient(70% 55% at 18% 86%, #30455f, transparent 72%),
linear-gradient(165deg, #22272f 0%, #0b0c0e 100%);
}
.cpr__img::before { /* the "wall of glass": a soft vertical light column */
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 56%, rgba(255,255,255,.07) 58%, rgba(255,255,255,.02) 74%, transparent 76%);
}
.cpr__img::after { /* film grain */
content: ''; position: absolute; inset: 0; opacity: .32; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
:root[data-theme="light"] .cpr__img { background: radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 30%, transparent), transparent 70%), radial-gradient(70% 55% at 18% 86%, #9fb3c8, transparent 72%), linear-gradient(165deg, #e6e2da 0%, #b8b2a6 100%); }
@media (max-width: 899px) {
.cpr__hero { grid-template-columns: 1fr; align-content: center; gap: 2rem; padding-block: 5rem 3rem; }
.cpr__media { max-height: 48vh; aspect-ratio: 4 / 3; order: -1; justify-self: stretch; }
.cpr__title { margin-top: .3em; }
.cpr__replay { margin-top: 1.75rem; }
}
/* reduced motion: no preloader, hero simply present */
@media (prefers-reduced-motion: reduce) {
.cpr__loader { display: none; }
.cpr__title { visibility: visible; }
.cpr__eyebrow, .cpr__sub { opacity: 1; }
.cpr__img { transform: none; }
.cpr__replay { display: none; }
}
```
```js
// Counter Preloader with Split Reveal — 0→100 serif counter, masked exits, panels split, hero rises.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { countDuration: 2.2, splitDuration: 1.4, splitEase: 'expo.inOut', imageScale: 1.15, charStagger: 0.02, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.cpr');
const loader = root.querySelector('.cpr__loader');
const panelTop = root.querySelector('.cpr__panel--top');
const panelBottom = root.querySelector('.cpr__panel--bottom');
const count = root.querySelector('.cpr__count');
const label = root.querySelector('.cpr__label');
const line = root.querySelector('.cpr__line');
const title = root.querySelector('.cpr__title');
const eyebrow = root.querySelector('.cpr__eyebrow');
const sub = root.querySelector('.cpr__sub');
const img = root.querySelector('.cpr__img');
const replay = root.querySelector('.cpr__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-image-scale', String(K('imageScale')));
const COUNT_DURATION = K('countDuration'); // seconds for 0 → 100
const SPLIT_DURATION = K('splitDuration'); // panel split
const splitEase = K('splitEase');
const imageScale = K('imageScale');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set([title, eyebrow, sub], { autoAlpha: 1 });
gsap.set(img, { scale: 1 });
return;
}
const counter = { v: 0 };
let tl;
const make = (chars, stagger) => {
tl = gsap.timeline({ defaults: { ease: 'power3.in' } });
tl.set(loader, { autoAlpha: 1 })
.set([panelTop, panelBottom, count, label], { yPercent: 0 })
.set(line, { scaleX: 0, transformOrigin: 'left center' })
.set(title, { autoAlpha: 1 })
// 1. count + line share one curve
.fromTo(counter, { v: 0 }, {
v: 100, duration: COUNT_DURATION, ease: 'power2.inOut', snap: { v: 1 },
onUpdate: () => { count.textContent = String(counter.v); },
}, 0)
.to(line, { scaleX: 1, duration: COUNT_DURATION, ease: 'power2.inOut' }, 0)
// 2. masked exits (accelerate away)
.to(label, { yPercent: -120, duration: 0.6 }, COUNT_DURATION + 0.05)
.to(count, { yPercent: -110, duration: 0.7 }, COUNT_DURATION + 0.15)
.to(line, { scaleX: 0, transformOrigin: 'right center', duration: 0.6 }, COUNT_DURATION + 0.2)
// 3. curtains
.to(panelTop, { yPercent: -100, duration: SPLIT_DURATION, ease: splitEase }, COUNT_DURATION + 0.7)
.to(panelBottom, { yPercent: 100, duration: SPLIT_DURATION, ease: splitEase }, '<')
// 4. hero (starts while the panels are still moving)
.fromTo(img, { scale: imageScale }, { scale: 1, duration: 1.6, ease: 'expo.out' }, COUNT_DURATION + 1.2)
.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger }, COUNT_DURATION + 1.3)
.fromTo([eyebrow, sub], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, COUNT_DURATION + 1.8)
.fromTo(replay, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, COUNT_DURATION + 2.1)
.set(loader, { autoAlpha: 0 });
return tl;
};
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const stagger = mmCtx.conditions.mobile ? K('charStagger') * 0.7 : K('charStagger');
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit: (self) => make(self.chars, stagger),
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });
```
### EFFECT (Framer & Webflow notes): Counter Preloader with Split Reveal
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withCounterPreloaderwithSplitReveal(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `cpr`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Counter Preloader with Split Reveal
**What it does (visual):** The page opens on a solid dark screen. Centred is a huge light-weight serif numeral (about 24vw tall) reading "0", with the mono word "LOADING" letter-spaced beneath it and a 1px hairline in `#d4ff3a` sitting 2.5rem above the bottom edge, spanning from 6vw to 94vw but scaled to zero width. Over 2.2 seconds the number counts up to 100 (integer steps, tabular digits so nothing jitters) while the hairline grows from the left to full width on the exact same ease — slow start, quick middle, slow landing. The moment the count lands, "LOADING" slides upward out of its clipping mask (0.6s, accelerating), then the numeral follows it up out of its own mask (0.7s), and the hairline retracts into its right end. A beat later the screen splits horizontally: the top half of the dark screen slides up off-screen and the bottom half slides down, both over 1.4s with a long held start and a fast finish (`expo.inOut`). Behind the opening panels a hero is already alive: a 4:5 image on the right settles from 1.15× to 100% scale over 1.6s, and the two-line serif headline on the left rises character by character (0.02s apart) out of a masked baseline. A mono eyebrow and a short paragraph fade and rise next, then a small outlined "Replay preloader" button appears. Clicking Replay rewinds and plays the entire sequence again.
**Tech stack:**
- GSAP 3.13+ core and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- Fonts: a variable serif for the numeral and headline (demo: Fraunces 300, `opsz 144`), a mono for labels, a sans for body copy.
**HTML structure:**
```html
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
```
(The demo paints `.cpr__img` with layered CSS gradients plus an SVG-noise grain; use an `<img>` or `background-image` in production.)
**CSS requirements:**
- `.cpr__loader`: `position: fixed; inset: 0; z-index: 10; pointer-events: none;`
- `.cpr__panel`: `position: absolute; left: 0; right: 0; height: 50%; background: var(--bg, #0a0a0a); will-change: transform;` — `--top` has `top: 0` and a `1px solid var(--line)` bottom border (reads as a seam when the split begins); `--bottom` has `bottom: 0`.
- `.cpr__center`: `position: absolute; inset: 0; display: grid; place-content: center; text-align: center;` (above the panels in source order, so it paints over them).
- `.cpr__mask`: `overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em;` (room for descenders).
- `.cpr__count`: display serif, `font-weight: 300; font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em; font-variant-numeric: tabular-nums; display: block;`
- `.cpr__label`: mono, `11px`, uppercase, `letter-spacing: .2em`, `color: var(--muted)`, mask has `margin-top: 1.75rem`.
- `.cpr__line`: `position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0);`
- `.cpr__hero`: `min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw;`
- `.cpr__title`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; visibility: hidden;` (JS sets `autoAlpha: 1` once split). `.line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em }`, `.char { display: inline-block; will-change: transform }`.
- `.cpr__eyebrow` (mono 11px uppercase, muted) and `.cpr__sub` (muted, `max-width: 42ch`, `line-height: 1.5`) start at `opacity: 0`.
- `.cpr__media`: `aspect-ratio: 4/5; max-height: 78vh; width: 100%; overflow: hidden; border-radius: 6px;` — `.cpr__img { position: absolute; inset: 0; transform: scale(1.15); will-change: transform; }` (the CSS start scale matches the tween so nothing jumps before JS runs).
- `.cpr__replay`: outlined pill (`border: 1px solid` 25% text colour, `border-radius: 999px; padding: .7em 1.4em`), `opacity: 0`, inverts on hover, visible focus ring in `--accent`.
- Under 900px: `.cpr__hero { grid-template-columns: 1fr }`, media first (`order: -1`, `aspect-ratio: 4/3; max-height: 48vh`).
**Animation spec (exact values):**
Let `C = 2.2` (the count duration — every later position is relative to it, so changing it re-times the whole sequence). Wait for `document.fonts.ready`, then `SplitText.create('.cpr__title', { type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto', onSplit: (self) => build(self.chars) })`. Inside `build` create ONE master timeline, `tl = gsap.timeline({ defaults: { ease: 'power3.in' } })` and `const counter = { v: 0 }`:
1. `tl.set('.cpr__loader', { autoAlpha: 1 }).set(['.cpr__panel--top', '.cpr__panel--bottom', '.cpr__count', '.cpr__label'], { yPercent: 0 }).set('.cpr__line', { scaleX: 0, transformOrigin: 'left center' }).set('.cpr__title', { autoAlpha: 1 })` — resets so `restart()` works.
2. Counter — `tl.fromTo(counter, { v: 0 }, { v: 100, duration: 2.2, ease: 'power2.inOut', snap: { v: 1 }, onUpdate: () => count.textContent = String(counter.v) }, 0)`.
3. Hairline grows — `tl.to('.cpr__line', { scaleX: 1, duration: 2.2, ease: 'power2.inOut' }, 0)` (identical curve to the counter).
4. Label exits — `tl.to('.cpr__label', { yPercent: -120, duration: 0.6, ease: 'power3.in' }, C + 0.05)`.
5. Numeral exits — `tl.to('.cpr__count', { yPercent: -110, duration: 0.7, ease: 'power3.in' }, C + 0.15)`.
6. Hairline retracts — `tl.to('.cpr__line', { scaleX: 0, transformOrigin: 'right center', duration: 0.6, ease: 'power3.in' }, C + 0.2)`.
7. Panels split — `tl.to('.cpr__panel--top', { yPercent: -100, duration: 1.4, ease: 'expo.inOut' }, C + 0.7)` and `tl.to('.cpr__panel--bottom', { yPercent: 100, duration: 1.4, ease: 'expo.inOut' }, '<')`.
8. Image settles — `tl.fromTo('.cpr__img', { scale: 1.15 }, { scale: 1, duration: 1.6, ease: 'expo.out' }, C + 1.2)`.
9. Headline chars — `tl.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger: 0.02 }, C + 1.3)`.
10. Eyebrow + paragraph — `tl.fromTo(['.cpr__eyebrow', '.cpr__sub'], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, C + 1.8)`.
11. Replay button — `tl.fromTo('.cpr__replay', { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, C + 2.1)`.
12. `tl.set('.cpr__loader', { autoAlpha: 0 })` at the end so the hidden loader cannot intercept anything.
Return `tl` from `onSplit` so SplitText keeps progress on re-split. Total ≈ C + 2.6s (≈4.8s at the default 2.2).
**Interaction:** No scroll or cursor dependency. `.cpr__replay` click → `tl.restart()` (every tween is `fromTo`/`set`, so state resets cleanly). Hover inverts the button over 0.25s.
**Responsive behavior:** With `gsap.matchMedia()`: `(min-width: 768px)` uses `stagger: 0.02`; `(max-width: 767px)` uses `stagger: 0.02 × 0.7` (more characters per line after wrapping keeps the headline ≈1s). The numeral is `clamp(6rem, 24vw, 19rem)` so it stays inside a 375px screen; the hero stacks under 900px via CSS.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` skip everything: `display: none` the loader, show the title/eyebrow/paragraph at full opacity, `.cpr__img { transform: none }`, hide the Replay button (do it in both CSS `@media` and JS so it works before JS). SplitText `aria: 'auto'` keeps the heading readable. Focus ring: `outline: 2px solid var(--accent); outline-offset: 3px`.
**Performance rules:** Only `transform` and `opacity` animate (yPercent, scaleX, scale, autoAlpha). `will-change: transform` lives on the panels, numeral, label, hairline and image only. Everything is inside `gsap.context()`; `ctx.revert()` on unmount also reverts the split. No `innerText` tweening — write `textContent` from the object counter. The host pauses `gsap.globalTimeline` off-screen; nothing else runs.
**Customization knobs:**
- `COUNT_DURATION` (2.2) — the whole sequence is positioned relative to it.
- `SPLIT_DURATION` (1.4) and its ease (`expo.inOut`; `power4.inOut` gives a softer curtain, `circ.inOut` a snappier one).
- Counter ease `power2.inOut` (use `none` for a mechanical linear count).
- Hero image start scale (1.15) and char `stagger` (0.02).
- Panel colour (`--bg`) and hairline colour (`--accent`: #d4ff3a).
**Acceptance criteria:**
- [ ] Numeral counts 0→100 in integer steps over 2.2s with no horizontal jitter; the hairline reaches full width at exactly the same moment.
- [ ] Label, then numeral, exit upward and are clipped by their masks (never visible above them).
- [ ] Panels split at C + 0.7s with `expo.inOut` over 1.4s; the hero image is already scaling and the headline characters start rising before the panels have left the screen.
- [ ] Replay restarts the full sequence from "0"; resizing re-splits without duplicating characters.
- [ ] Reduced-motion users see the hero immediately and never see the loader.
- [ ] No console errors, no layout shift when the headline is split.
Source
Full source code
<div class="cpr">
<div class="cpr__loader" aria-hidden="true">
<div class="cpr__panel cpr__panel--top"></div>
<div class="cpr__panel cpr__panel--bottom"></div>
<div class="cpr__center">
<div class="cpr__mask"><span class="cpr__count">0</span></div>
<div class="cpr__mask cpr__mask--label"><span class="cpr__label">Loading</span></div>
</div>
<div class="cpr__line"></div>
</div>
<section class="cpr__hero">
<div class="cpr__copy">
<p class="cpr__eyebrow">Atelier Nord · Residential · 2026</p>
<h1 class="cpr__title">Light finds the quiet rooms</h1>
<p class="cpr__sub">A hillside house in three volumes — concrete, oak and a single wall of glass that turns the valley into the fourth room.</p>
<button class="cpr__replay" type="button">Replay preloader</button>
</div>
<div class="cpr__media"><div class="cpr__img"></div></div>
</section>
</div>
.cpr { --cpr-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.cpr__loader { position: fixed; inset: 0; z-index: 10; pointer-events: none; }
.cpr__panel { position: absolute; left: 0; right: 0; height: 50%; background: var(--bg); will-change: transform; }
.cpr__panel--top { top: 0; border-bottom: 1px solid var(--line); }
.cpr__panel--bottom { bottom: 0; }
.cpr__center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.cpr__mask { overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em; }
.cpr__count {
display: block; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(6rem, 24vw, 19rem); line-height: .85; letter-spacing: -0.05em;
font-variant-numeric: tabular-nums; color: var(--text); will-change: transform;
}
.cpr__mask--label { margin-top: 1.75rem; }
.cpr__label { display: block; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .2em; color: var(--muted); will-change: transform; }
.cpr__line { position: absolute; left: 6vw; right: 6vw; bottom: 2.5rem; height: 1px; background: var(--cpr-accent); transform-origin: left; transform: scaleX(0); will-change: transform; }
/* ---------- hero ---------- */
.cpr__hero {
position: relative; min-height: 100vh; display: grid; grid-template-columns: 1.1fr .9fr; align-items: center;
gap: clamp(2rem, 5vw, 6rem); padding: clamp(1.5rem, 6vw, 5rem) 6vw; background: var(--bg); color: var(--text);
}
.cpr__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); opacity: 0; }
.cpr__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; margin: .6em 0 .5em;
visibility: hidden; text-wrap: balance;
}
.cpr__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.cpr__title .char { display: inline-block; will-change: transform; }
.cpr__sub { max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; opacity: 0; }
.cpr__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.cpr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.cpr__replay:focus-visible { outline: 2px solid var(--cpr-accent); outline-offset: 3px; }
.cpr__media { position: relative; aspect-ratio: 4 / 5; max-height: 78vh; justify-self: end; width: 100%; overflow: hidden; border-radius: 6px; background: var(--surface); }
.cpr__img {
position: absolute; inset: 0; transform: scale(var(--knob-image-scale, 1.15)); will-change: transform;
background:
radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 28%, transparent), transparent 70%),
radial-gradient(70% 55% at 18% 86%, #30455f, transparent 72%),
linear-gradient(165deg, #22272f 0%, #0b0c0e 100%);
}
.cpr__img::before { /* the "wall of glass": a soft vertical light column */
content: ''; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 56%, rgba(255,255,255,.07) 58%, rgba(255,255,255,.02) 74%, transparent 76%);
}
.cpr__img::after { /* film grain */
content: ''; position: absolute; inset: 0; opacity: .32; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
:root[data-theme="light"] .cpr__img { background: radial-gradient(55% 45% at 72% 24%, color-mix(in srgb, var(--cpr-accent) 30%, transparent), transparent 70%), radial-gradient(70% 55% at 18% 86%, #9fb3c8, transparent 72%), linear-gradient(165deg, #e6e2da 0%, #b8b2a6 100%); }
@media (max-width: 899px) {
.cpr__hero { grid-template-columns: 1fr; align-content: center; gap: 2rem; padding-block: 5rem 3rem; }
.cpr__media { max-height: 48vh; aspect-ratio: 4 / 3; order: -1; justify-self: stretch; }
.cpr__title { margin-top: .3em; }
.cpr__replay { margin-top: 1.75rem; }
}
/* reduced motion: no preloader, hero simply present */
@media (prefers-reduced-motion: reduce) {
.cpr__loader { display: none; }
.cpr__title { visibility: visible; }
.cpr__eyebrow, .cpr__sub { opacity: 1; }
.cpr__img { transform: none; }
.cpr__replay { display: none; }
}
// Counter Preloader with Split Reveal — 0→100 serif counter, masked exits, panels split, hero rises.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { countDuration: 2.2, splitDuration: 1.4, splitEase: 'expo.inOut', imageScale: 1.15, charStagger: 0.02, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.cpr');
const loader = root.querySelector('.cpr__loader');
const panelTop = root.querySelector('.cpr__panel--top');
const panelBottom = root.querySelector('.cpr__panel--bottom');
const count = root.querySelector('.cpr__count');
const label = root.querySelector('.cpr__label');
const line = root.querySelector('.cpr__line');
const title = root.querySelector('.cpr__title');
const eyebrow = root.querySelector('.cpr__eyebrow');
const sub = root.querySelector('.cpr__sub');
const img = root.querySelector('.cpr__img');
const replay = root.querySelector('.cpr__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-image-scale', String(K('imageScale')));
const COUNT_DURATION = K('countDuration'); // seconds for 0 → 100
const SPLIT_DURATION = K('splitDuration'); // panel split
const splitEase = K('splitEase');
const imageScale = K('imageScale');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set([title, eyebrow, sub], { autoAlpha: 1 });
gsap.set(img, { scale: 1 });
return;
}
const counter = { v: 0 };
let tl;
const make = (chars, stagger) => {
tl = gsap.timeline({ defaults: { ease: 'power3.in' } });
tl.set(loader, { autoAlpha: 1 })
.set([panelTop, panelBottom, count, label], { yPercent: 0 })
.set(line, { scaleX: 0, transformOrigin: 'left center' })
.set(title, { autoAlpha: 1 })
// 1. count + line share one curve
.fromTo(counter, { v: 0 }, {
v: 100, duration: COUNT_DURATION, ease: 'power2.inOut', snap: { v: 1 },
onUpdate: () => { count.textContent = String(counter.v); },
}, 0)
.to(line, { scaleX: 1, duration: COUNT_DURATION, ease: 'power2.inOut' }, 0)
// 2. masked exits (accelerate away)
.to(label, { yPercent: -120, duration: 0.6 }, COUNT_DURATION + 0.05)
.to(count, { yPercent: -110, duration: 0.7 }, COUNT_DURATION + 0.15)
.to(line, { scaleX: 0, transformOrigin: 'right center', duration: 0.6 }, COUNT_DURATION + 0.2)
// 3. curtains
.to(panelTop, { yPercent: -100, duration: SPLIT_DURATION, ease: splitEase }, COUNT_DURATION + 0.7)
.to(panelBottom, { yPercent: 100, duration: SPLIT_DURATION, ease: splitEase }, '<')
// 4. hero (starts while the panels are still moving)
.fromTo(img, { scale: imageScale }, { scale: 1, duration: 1.6, ease: 'expo.out' }, COUNT_DURATION + 1.2)
.fromTo(chars, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger }, COUNT_DURATION + 1.3)
.fromTo([eyebrow, sub], { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.9, ease: 'power3.out', stagger: 0.1 }, COUNT_DURATION + 1.8)
.fromTo(replay, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: 'power2.out' }, COUNT_DURATION + 2.1)
.set(loader, { autoAlpha: 0 });
return tl;
};
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const stagger = mmCtx.conditions.mobile ? K('charStagger') * 0.7 : K('charStagger');
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit: (self) => make(self.chars, stagger),
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });