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.

Loading

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.

prompt.md · Counter Preloader with Split Reveal
### 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>