L3

·

Load · choreography

Preloader with a real handoff

The loader is the only moment on a website where the visitor expects to wait, which makes it the cheapest second of brand you will ever buy — and the easiest to get wrong. Three rules. The counter must track real asset loading, because a fake 0–100 that finishes before the images do produces a hero full of empty boxes. There must be a minimum duration, because a loader that flashes for 180ms on a fast connection is worse than no loader. And the hero must be a paused timeline that the loader plays, overlapping the curtain by half a second — that overlap is the difference between one continuous piece of choreography and two animations queued up back to back.

Open the live demo →

How to build

Panel + script

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

gsap core · Timeline

Build time

1 hour. Budget another 30 min for the session logic.

No-code?

The panel can build a Page Load interaction that looks like this, but it has no access to load progress and no way to gate on real assets. Panel loaders are always fake timers.

01

Webflow structure

element · class · attribute

<b>Div</b> <i>loader_component</i> ← fixed inset 0, z 300, pointer-events none <b>Div</b> <i>loader_cols</i> ← display flex, absolute inset 0 <b>Div</b> <i>loader_col</i> ×5 ← flex: 1 <b>Div</b> <i>loader_inner</i> ← z 2, flex column, justify end <b>Div</b> <i>loader_word</i> ← overflow: hidden (the mask) <b>Text</b> <i>loader_letter</i> A V E R Y ← one Text Block per letter <b>Div</b> <i>loader_meta</i> <b>Text</b> <i>loader_status</i> <b>Text</b> <i>loader_count</i> ← font-variant-numeric: tabular-nums <b>Div</b> <i>loader_bar</i> <b>Div</b> <i>loader_bar-fill</i> ← scaleX 0, origin left <b>Section</b> <i>section_hero</i> ← the thing the loader hands off to <b>Text</b> <i>hero_eyebrow</i> <b>Heading H1</b> <i>hero_h1</i> <b>Div</b> <i>hero_foot</i>

02

Styles to set in the Designer

loader_component | <code>position: fixed; inset: 0; z-index: 300; pointer-events: none</code>. Pointer-events none from the start, so a stuck loader can never block the whole site. loader_word | <code>display: flex; overflow: hidden</code>. Letters as separate elements rather than SplitText — the loader must run before fonts are guaranteed, and SplitText needs the font. loader_count | <code>font-variant-numeric: tabular-nums</code>. Without it the counter's width jitters on every digit change and it looks broken. loader_col | <code>flex: 1</code>. Five columns, staggered up by 0.07s each — a diagonal wipe out of five rectangles, and no clip-path needed. section_hero | Nothing hidden in the Designer. GSAP's <code>.from()</code> sets the start state, so the page is readable with JS off.

03

Building it with the Interactions panel

no code, free plan
  1. Add a Page load interaction on loader_component: stagger loader_letter in with Move Y 110% → 0%, Ease Expo Out, then reverse the whole group out after a fixed delay.
  2. Give the hero its own Page load interaction with a small negative Start delay so it begins a beat before the loader's exit finishes, rather than queuing strictly after it.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • The counter and progress bar can't be real — the panel has no access to actual image-load events, so any number you animate is a fixed-duration guess, not state.p tied to real assets via Promise.all().
  • No session gating — there's no built-in "only show this once per session" condition; that needs sessionStorage, which is script-only.
  • No error handling — a 404'd image can't be caught and skipped the way img.addEventListener("error", …) does, so a fake timer is also the safer choice: it can't hang.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { const loader = document.querySelector(".loader_component"); const counter = document.querySelector(".loader_count"); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; // ── the hero, built PAUSED. It is a separate object with one job. ── const hero = gsap.timeline({ paused: true, defaults: { ease: "expo.out" } }) .from(".hero_eyebrow", { opacity: 0, y: 16, duration: 0.7 }) .from(".hero_h1", { opacity: 0, y: 44, duration: 1.1 }, "-=0.5") .from(".hero_foot > *", { opacity: 0, y: 24, duration: 0.9, stagger: 0.08 }, "-=0.75"); // Once per session, not once per page view. Nobody wants your loader // three times on the way to the contact form. if (reduced || sessionStorage.getItem("seenLoader")) { gsap.set(loader, { display: "none" }); hero.play(); return; } sessionStorage.setItem("seenLoader", "1"); // ── real progress ─────────────────────────────────────────────────── const imgs = Array.from(document.images); const state = { p: 0 }; const render = () => counter.textContent = String(Math.round(state.p * 100)).padStart(3, "0"); let done = 0; const bump = () => { done++; const p = done / Math.max(imgs.length, 1); gsap.to(state, { p, duration: 0.5, ease: "power2.out", onUpdate: render }); gsap.to(".loader_bar-fill", { scaleX: p, duration: 0.5, ease: "power2.out" }); }; const assets = Promise.all(imgs.map(img => new Promise(res => { if (img.complete) { bump(); return res(); } img.addEventListener("load", () => { bump(); res(); }, { once: true }); img.addEventListener("error", () => { bump(); res(); }, { once: true }); // never hang on a 404 }))); // The floor. A loader that flashes is worse than no loader at all. const floor = new Promise(res => gsap.delayedCall(1.1, res)); const intro = gsap.timeline({ defaults: { ease: "expo.out" } }) .from(".loader_letter", { yPercent: 110, duration: 1, stagger: 0.055 }) .from(".loader_status", { opacity: 0, duration: 0.6 }, "-=0.6") .from(".loader_count", { opacity: 0, duration: 0.6 }, "<"); Promise.all([assets, floor, intro.then()]).then(() => { gsap.timeline({ defaults: { ease: "expo.inOut" }, onComplete: () => gsap.set(loader, { display: "none" }) }) .to(".loader_letter", { yPercent: -110, duration: 0.8, stagger: 0.04 }) .to([".loader_status", ".loader_count"], { opacity: 0, duration: 0.4 }, "-=0.6") .to(".loader_col", { yPercent: -101, duration: 1, stagger: 0.07 }, "-=0.35") // THE HANDOFF. Negative offset: the hero begins while the curtain is // still moving. Queue them back to back and it reads as two animations. .add(() => hero.play(), "-=0.55"); }); }); </script>

07

Gotchas that will cost you an hour

  1. Always resolve on error as well as load. One 404 image and a loader that only listens for load hangs forever, on the client's site, permanently. This is the single worst bug in this lesson.
  2. sessionStorage, not localStorage. Session means a returning visitor next week still sees the brand moment; local means they never see it again and you cannot demo it.
  3. The hero must be paused, not delayed. A delay: 2.4 on the hero is a guess about how long loading takes. On a slow connection the hero plays behind the curtain and the visitor sees nothing.
  4. Do not use SplitText in the loader. It runs before fonts.ready, so the split is measured against the fallback font. Hand-write the letters as separate elements.
  5. pointer-events: none on the loader from the start. If anything in the chain throws, the site still works — it just has a stuck rectangle on top instead of being entirely dead.
  6. Webflow's Editor loads inside an iframe with its own chrome. Test the loader on the published staging domain, not in Designer preview, or the fixed positioning will lie to you.
08

Push it further

  • Swap the column curtain for a clip-path: circle() expanding from where the visitor's cursor was — pointer position captured on the first mousemove.
  • Feed the counter into a SplitText scramble so the digits shuffle instead of ticking. The Osmo scramble text, 20 lines.
  • On a CMS-heavy page, weight the progress by naturalWidth × naturalHeight instead of counting files, so a 2MB hero moves the bar more than a 4KB icon.

Webflow Motion Lab — GSAP 3.15, all plugins free.

22

/ 30