Intro Loader
A page-load intro that counts to 100 with the real loading while a flipbook of your work plays in a small frame, then grows the last image into its place on the page. It doesn't fade out: its last frame is the page's first.
- Preloader
- Hero
- Image
Uses clsx, tailwind-merge
We design thingspeople remember
"use client";
import * as React from "react";
import {
IntroLoader,
IntroLoaderLine,
IntroLoaderTarget,
useIntroDone,
} from "@/components/ui/intro-loader";
const image = (id: string, w: number, h: number) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;
// The last image is the hero, at the same address as the one on the page, so
// it downloads once and lands on itself.
const hero = image("1600585154340-be6161a56a0c", 1800, 1100);
const work = [
image("1561070791-2526d30994b5", 480, 600),
image("1487958449943-2429e8be8625", 480, 600),
image("1585386959984-a4155224a1ad", 480, 600),
image("1505740420928-5e560c06d30e", 480, 600),
hero,
];
// The reload button plays it again by remounting it with a new key. On a real
// site, drop `contained` and `once={false}`: it covers the screen, once per
// visit.
export function IntroLoaderDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[#f4f3ef] text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
<BrowserBar url="nord.studio" run={run} onReload={() => setRun((r) => r + 1)} />
<IntroLoader
key={run}
contained
once={false}
images={work}
// Set like the logo in the header, so the page's logo takes its place.
brand={<span className="text-sm leading-5 font-semibold tracking-tight">Nord Studio</span>}
className="@container flex min-h-0 flex-1 flex-col"
>
<header className="flex items-start justify-between gap-6 px-5 pt-5 text-sm leading-5 sm:px-8 sm:pt-8">
<span className="font-semibold tracking-tight">Nord Studio</span>
<nav className="flex gap-5 sm:gap-7">
{["Work", "About", "Contact"].map((item) => (
<IntroLoaderLine key={item} from="above">
<a
href={`#${item.toLowerCase()}`}
className="text-zinc-600 transition-colors duration-150 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
{item}
</a>
</IntroLoaderLine>
))}
</nav>
</header>
<section className="relative mt-5 min-h-0 flex-1 overflow-hidden sm:mt-8">
<IntroLoaderTarget>
<img
src={hero}
alt="A dark timber house lit from inside at dusk, behind a eucalyptus tree"
className="absolute inset-0 size-full object-cover"
/>
</IntroLoaderTarget>
<Scrim />
<div className="relative flex h-full flex-col justify-end p-5 text-white sm:p-8">
<h1 className="text-[clamp(2.25rem,7cqw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.045em]">
<IntroLoaderLine>We design things</IntroLoaderLine>
<IntroLoaderLine>people remember</IntroLoaderLine>
</h1>
<div className="mt-4 flex items-end justify-between gap-6 text-sm text-white/75 sm:mt-6">
<IntroLoaderLine className="max-w-xs text-pretty">
Identity, products and spaces for companies that care how things feel.
</IntroLoaderLine>
<IntroLoaderLine className="hidden shrink-0 tabular-nums @xl:block">
Halden House, 2026
</IntroLoaderLine>
</div>
</div>
</section>
</IntroLoader>
</div>
);
}
/**
* Darkens the bottom of the hero for the headline. It isn't part of the image
* the loader lands, so it waits for the handoff and fades in after it.
*/
function Scrim() {
const done = useIntroDone();
return (
<div
aria-hidden
className={`absolute inset-0 bg-linear-to-t from-black/55 via-black/10 to-transparent to-60% transition-opacity duration-700 ease-out ${done ? "opacity-100" : "opacity-0"}`}
/>
);
}
function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
return (
<div className="flex h-10 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white/60 px-3 dark:border-zinc-800 dark:bg-zinc-900/60">
<div aria-hidden className="flex gap-1.5">
{[0, 1, 2].map((dot) => (
<span key={dot} className="size-2.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
))}
</div>
<button
type="button"
onClick={onReload}
aria-label="Reload to play it again"
title="Play again"
className="grid size-7 place-items-center rounded-md text-zinc-500 transition-[background-color,color,scale] duration-150 hover:bg-zinc-200/70 hover:text-zinc-900 active:scale-[0.94] dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ rotate: `${run * 360}deg` }}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</svg>
</button>
<span className="mx-auto truncate rounded-md bg-zinc-100 px-3 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-800/80 dark:text-zinc-400">
{url}
</span>
<span aria-hidden className="w-[4.75rem]" />
</div>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/intro-loader.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/intro-loaderInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/intro-loader.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * INTRO LOADER STORYBOARD (on a fast connection) * * 0.0s the first paint is already the loader, in the page's * own background. A tiny inline script decides before * the page under it is drawn, so nothing flashes, even * before React loads. The counter rises in at 000 * 0→1.6s count: 000 to 100 in uneven jumps, following the fonts * and images. Each digit rolls up into place * flip: each image wipes up over the last in the center * frame, on the count, each one a little sooner than the * one before. The last comes in at 88 * 1.6s breath: 100. The counter slides up out of its mask and * the last image holds * 2.2→3.1 handoff: the last image grows from the frame to exactly * where the target sits on the page. It was laid out at * the target's size all along, only scaled down and * masked to the frame, so it lands as the target itself. * The loader's background clears as it lands * 3.1s reveal: the loader is gone, lines rise into place, the * nav drops in and scrolling unlocks * * Everything that moves is a Web Animation of a transform, a * clip or an opacity. The only script that runs each frame is * the count, so it stays smooth while React hydrates. * ───────────────────────────────────────────────────────── */ const INTRO = { steps: [0.1, 0.38], // s between count updates, picked at random, so it jumps unevenly crawl: 0.85, // how far the count creeps by maxDuration while assets are still loading enter: { duration: 0.9, ease: [0.23, 1, 0.32, 1] }, // the counter and brand rising in roll: { duration: 0.5, ease: [0.23, 1, 0.32, 1] }, // a digit rolling to its next value; it lands well before the next jump pace: 0.7, // each image shows for this share of the time the one before did last: 0.88, // where in the count the last image comes in first: 0.42, // s the first image shows, at least; later ones shrink from it by `pace` gap: 0.12, // s between letters, and between images left at 100, at least wipe: { duration: 0.75, ease: [0.23, 1, 0.32, 1], zoom: 1.25 }, // an image wiping up as it settles letters: { from: 0.04, to: 0.8, duration: 0.9, ease: [0.23, 1, 0.32, 1] }, // the mark, across the count settled: 0.5, // share of a wipe or rise after which the piece counts as in hold: 0.12, // s at 100 before the counter leaves leave: { duration: 0.6, ease: [0.77, 0, 0.175, 1] }, breath: 0.28, // s the last image holds, still, before the handoff grow: { duration: 0.9, ease: [0.77, 0, 0.175, 1], clear: 0.4 }, // `clear`: last share, over which the background goes rise: { duration: 0.9, ease: [0.23, 1, 0.32, 1], stagger: 0.07 }, out: { duration: 0.35, ease: [0.23, 1, 0.32, 1] }, // fading out on a skip, or with nowhere to land fade: { duration: 0.5, ease: [0.23, 1, 0.32, 1], delay: 0.15 }, // reduced motion // Resting places while hidden. Keep them in step with the classes that // set them before the script runs. hidden: { riser: "translateY(140%)", leave: "translateY(-140%)", glyph: "translateY(115%)", below: "translateY(115%)", above: "translateY(-115%)", }, } as const; type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type Plan = { once: boolean; key: string; min: number; max: number; skippable: boolean; contained: boolean; skip: boolean; images: string[]; t: typeof INTRO; }; type Handle = { done: boolean; on: (listener: () => void) => () => void; destroy: () => void; }; type LoaderRoot = HTMLElement & { __introLoader?: Handle }; /** A box in the loader's coordinates, with its top-left corner radius. */ type Spot = { x: number; y: number; w: number; h: number; r: number }; /** * The whole sequence, from the first digit to the last line settling. It runs * as an inline <script> while the HTML is still parsing, so the loader is up * on the first paint, and from an effect when the component mounts on the * client. * * It's serialized with toString(), so it must stay self-contained: no outer * variables, no imports, nothing a compiler would turn into a helper (async, * object spread). State lives on the root as data-intro-loader: play, then * reveal, then done; or fade, out or skip. */ function introLoader(root: HTMLElement, o: Plan): Handle { const t = o.t; let listeners: Array<() => void> = []; let cleanups: Array<() => void> = []; let anims: Animation[] = []; let frame = 0; // 0 counting, 1 at 100, 2 handing off, 3 revealing or fading out, 4 done let phase = 0; const release = () => { cleanups.forEach((c) => c()); cleanups = []; }; const handle: Handle = { done: false, on(listener) { if (handle.done) listener(); else listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; }, destroy() { phase = 4; cancelAnimationFrame(frame); release(); anims.forEach((a) => a.cancel()); anims = []; listeners = []; }, }; (root as LoaderRoot).__introLoader = handle; const finish = () => { if (handle.done) return; handle.done = true; const due = listeners; listeners = []; due.forEach((l) => l()); }; const state = (value: string) => root.setAttribute("data-intro-loader", value); // Seen this session, or arriving at a section by its link: straight in. let seen = false; try { seen = o.once && sessionStorage.getItem(o.key) === "1"; } catch {} if ( o.skip || seen || typeof root.animate !== "function" || (!o.contained && location.hash.length > 1) ) { state("skip"); finish(); return handle; } const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; state(reduce ? "fade" : "play"); // The loader wears the page's own background, so where the page shows // through at the end there's no seam: the nearest one that isn't clear. for (let el: HTMLElement | null = root; el; el = el.parentElement) { const bg = getComputedStyle(el).backgroundColor; if (bg && bg !== "transparent" && !/^rgba\(.*,\s*0\)$|\/\s*0\)$/.test(bg)) { root.style.setProperty("--intro-loader-bg", bg); break; } } const run = ( el: Element, keyframes: Keyframe[], timing: { duration: number; ease: readonly number[] }, delay = 0, fill: FillMode = "backwards", ) => { const a = el.animate(keyframes, { duration: timing.duration * 1000, delay: delay * 1000, easing: `cubic-bezier(${timing.ease.join(",")})`, fill, }); anims.push(a); return a; }; // The page is still parsing when this first runs, so the loader may not // exist yet. Look again every frame until parsing is done. let parsed = false; let cover: HTMLElement | null = null; let box: HTMLElement | null = null; // the center frame let flier: HTMLElement | null = null; // the last image let mark: HTMLElement | null = null; let word: HTMLElement | null = null; // the mark set whole, kerned and unseen let counter: HTMLElement | null = null; let risers: HTMLElement[] = []; let digits: HTMLElement[] = []; let shots: HTMLElement[] = []; let glyphs: HTMLElement[] = []; const find = () => { if (parsed) return; parsed = document.readyState !== "loading"; const c = root.querySelector<HTMLElement>("[data-intro-loader-cover]"); cover = c; if (!c) return; const all = (selector: string) => Array.from(c.querySelectorAll<HTMLElement>(selector)); box = c.querySelector("[data-intro-loader-frame]"); flier = c.querySelector("[data-intro-loader-flier]"); mark = c.querySelector("[data-intro-loader-mark]"); word = c.querySelector("[data-intro-loader-word]"); counter = c.querySelector("[data-intro-loader-counter]"); risers = all("[data-intro-loader-in]"); digits = all("[data-intro-loader-digit]"); shots = all("[data-intro-loader-shot]"); glyphs = all("[data-intro-loader-glyph]"); fill(); }; const listen = (target: EventTarget, type: string, fn: (event: Event) => void) => { target.addEventListener(type, fn, { passive: false }); cleanups.push(() => target.removeEventListener(type, fn)); }; // A click or a key goes straight to the page. Modifiers don't, so a // screenshot shortcut doesn't skip the moment it's trying to catch. const skip = (event: Event) => { if (!o.skippable || phase > 2) return; if (event.type === "keydown") { const k = event as KeyboardEvent; if (k.metaKey || k.ctrlKey || k.altKey || /^(Shift|Meta|Control|Alt|CapsLock|Fn|OS)$/.test(k.key)) return; } else if ((event as MouseEvent).button !== 0) { return; } out(); }; if (!reduce) { if (o.contained) { listen(root, "click", skip); } else { // Lock the page without touching its styles, so nothing shifts when the // scrollbar would otherwise come and go. const keys = [" ", "PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; const block = (event: Event) => { if (event.type !== "keydown" || keys.indexOf((event as KeyboardEvent).key) > -1) { event.preventDefault(); } }; listen(window, "wheel", block); listen(window, "touchmove", block); listen(window, "keydown", (event) => { block(event); skip(event); }); listen(window, "click", skip); } } // A loader over part of the page waits until it's on screen. let started = !o.contained; if (!started) { const io = new IntersectionObserver( (entries) => { if (!entries[0].isIntersecting) return; started = true; io.disconnect(); fetchImages(); }, { rootMargin: "0px 0px -15% 0px" }, ); io.observe(root); cleanups.push(() => io.disconnect()); } // Loading: the flipbook, fetched as soon as it starts, before the HTML that // shows it has even arrived; then the fonts and every image in view on the // page. A loader further down the page fetches nothing until it's on // screen, so it doesn't hold up one that is. let total = 0; let loaded = 0; const track = (promise: Promise<unknown>) => { total++; const done = () => { loaded++; }; promise.then(done, done); }; const ready: Array<boolean | undefined> = []; // per image: decoded, or failed let fetched = false; // The loader's own images carry their address in data-src until it plays. function fill() { if (!fetched || !cover) return; cover.querySelectorAll("img[data-src]").forEach((img) => { if (!img.getAttribute("src")) img.setAttribute("src", img.getAttribute("data-src") || ""); }); } function fetchImages() { if (fetched || reduce) return; fetched = true; fill(); o.images.forEach((src, i) => { const img = new Image(); img.src = src; track( img.decode().then( () => { ready[i] = true; }, () => { ready[i] = img.complete && img.naturalWidth > 0; }, ), ); }); } if (started) fetchImages(); const view = () => o.contained ? root.getBoundingClientRect() : { top: 0, left: 0, bottom: innerHeight, right: innerWidth }; let scanned = false; const scan = () => { scanned = true; if (document.fonts) track(document.fonts.ready); const v = view(); root.querySelectorAll("img").forEach((img) => { if (cover && cover.contains(img)) return; // An image in a line is pushed out of its mask; where it shows is the mask. const r = (img.closest("[data-intro-loader-line]") || img).getBoundingClientRect(); if (r.bottom < v.top || r.top > v.bottom || r.right < v.left || r.left > v.right) return; track(img.decode()); }); }; const spot = (el: Element): Spot => { const r = el.getBoundingClientRect(); const c = cover ? cover.getBoundingClientRect() : r; return { x: r.left - c.left, y: r.top - c.top, w: r.width, h: r.height, r: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, }; }; // Where the target sits under the loader, if it's on screen. A target with // no box of its own (display: contents) is measured by its first child. const target = () => { const marker = root.querySelector("[data-intro-loader-target]"); if (!marker || !cover || cover.contains(marker)) return null; // The page sits at its top under the loader, wherever the browser restored it to. if (!o.contained && (scrollX || scrollY)) scrollTo({ top: 0, left: 0, behavior: "instant" }); const el = marker.getClientRects().length ? marker : marker.firstElementChild; if (!el) return null; const s = spot(el); const c = cover.getBoundingClientRect(); if (s.w < 1 || s.h < 1 || s.x + s.w <= 0 || s.y + s.h <= 0 || s.x >= c.width || s.y >= c.height) { return null; } if (!s.r && el.firstElementChild) { s.r = parseFloat(getComputedStyle(el.firstElementChild).borderTopLeftRadius) || 0; } const media = el.matches("img,video") ? el : el.querySelector("img,video"); return { spot: s, fit: media ? getComputedStyle(media).objectPosition : "" }; }; // The last image is laid out at the target's size from the start, then // scaled down and masked to the frame. Landing is only undoing that, so its // last frame is the target, pixel for pixel, at any screen size. let aim: { to: Spot | null; mask: string; from: string } | null = null; const place = () => { if (!flier || !box) return false; const f = spot(box); if (!f.w || !f.h) return false; const found = target(); const b = found ? found.spot : f; const s = Math.max(f.w / b.w, f.h / b.h); const w = f.w / s; // the frame, in the image's own pixels const h = f.h / s; const l = (b.w - w) / 2; const top = (b.h - h) / 2; const r = f.r / s; flier.style.left = `${b.x}px`; flier.style.top = `${b.y}px`; flier.style.width = `${b.w}px`; flier.style.height = `${b.h}px`; flier.style.transform = `translate(${f.x - b.x - s * l}px, ${f.y - b.y - s * top}px) scale(${s})`; flier.style.clipPath = `inset(${top}px ${l}px ${top}px ${l}px round ${r}px)`; const img = flier.firstElementChild as HTMLElement | null; if (img) { img.style.objectPosition = found ? found.fit : ""; img.style.transformOrigin = `${l + w / 2}px ${top + h / 2}px`; } aim = { to: found ? found.spot : null, mask: flier.style.clipPath, from: `inset(${b.h - top}px ${l}px ${top}px ${l}px round ${r}px)`, }; return true; }; // Split letters lose their kerning. Line each one up with where it falls in // the whole word, so the mark lands exactly on a logo set in the same type. const kern = () => { const text = word && word.firstChild; if (!word || !text || text.nodeType !== 3) return; const range = document.createRange(); const origin = word.getBoundingClientRect().left; let offset = 0; glyphs.forEach((glyph) => { const mask = glyph.parentElement; const length = (glyph.textContent || "").length; if (!mask || offset + length > (text as Text).length) return; range.setStart(text, offset); range.setEnd(text, offset + length); offset += length; mask.style.transform = `translateX(${range.getBoundingClientRect().left - origin - mask.offsetLeft}px)`; }); }; const enter = () => { risers.forEach((el) => { if (el.style.transform) return; el.style.transform = "none"; run(el, [{ transform: t.hidden.riser }, { transform: "none" }], t.enter); }); }; // Each digit rolls forward to its next value, through the ones between. // A strip holds 0-9 twice, so 7 to 2 rolls on through 8, 9 and 0. const roll = (value: number) => { const text = String(value).padStart(digits.length, "0"); digits.forEach((strip, i) => { const size = strip.offsetHeight / 20; if (!size) return; let at = -new DOMMatrixReadOnly(getComputedStyle(strip).transform).m42 / size; if (at > 9.99) at -= 10; // the same digit, on the first run let to = Number(text.charAt(i)); while (to < at - 0.01) to += 10; if (Math.abs(to - at) < 0.01) return; strip.getAnimations().forEach((a) => a.cancel()); strip.style.transform = `translateY(${-to * 5}%)`; run(strip, [{ transform: `translateY(${-at * 5}%)` }, { transform: strip.style.transform }], t.roll); }); }; // Clock, in seconds of visible time: a backgrounded tab pauses the intro // instead of jumping it to the end when it comes back. A slow frame still // counts in full, so a phone busy hydrating the page doesn't stretch it. let elapsed = 0; let last = 0; let goal = 0; let value = 0; let step = 0; // when the count may next move let landed = 0; // when it reached 100 let shot = 0; // next image to come in let glyph = 0; // next letter to rise let next = 0; // when the next image or letter may come in let settled = 0; // when the subject is all in let marked = false; // remembered for the session, from the first counted frame const n = o.images.length; const count = () => { const cap = Math.max(o.max, o.min); // The count may not pass the clock, so it never lands before minDuration, // nor pass the loading, which creeps on its own so it never stalls. const real = scanned ? (total ? loaded / total : 1) : 0; const done = (scanned && loaded >= total) || elapsed >= cap; const clock = o.min > 0 ? Math.min(1, elapsed / o.min) : 1; const creep = real + (1 - real) * t.crawl * (1 - Math.exp((-3 * elapsed) / cap)); goal = Math.max(goal, Math.min(clock, done ? 1 : creep)); if (elapsed >= step || goal >= 1) { step = elapsed + t.steps[0] + Math.random() * (t.steps[1] - t.steps[0]); const shown = Math.floor(goal * 100 + 1e-6); if (shown > value) { value = shown; roll(value); } } if (value < 100 || !parsed) return; phase = 1; landed = elapsed; if (counter) { counter.getAnimations().forEach((a) => a.cancel()); counter.style.transform = t.hidden.leave; run(counter, [{ transform: "none" }, { transform: t.hidden.leave }], t.leave, t.hold); } if (!n && !glyphs.length) settled = landed + t.hold + t.leave.duration * t.settled; }; // The subject keeps time with the count: images each due a little sooner // than the one before, letters evenly. Each image also holds a little less // than the one before, so one that loads late doesn't bunch up the rest. // At 100 the loaded images left go by in a quick flurry; out of time, it // goes straight to the last. const pace = () => { if (elapsed < next) return; const p = value / 100; if (shot < n) { if (elapsed >= Math.max(o.max, o.min)) shot = Math.max(shot, n - 1); const i = shot; const due = n < 2 ? 0 : (t.last * (1 - Math.pow(t.pace, i))) / (1 - Math.pow(t.pace, n - 1)); if (value < 100 && (p < due || ready[i] === undefined)) return; const el = i < n - 1 ? shots[i] : flier; if (!el) return; // not parsed yet shot++; if (i < n - 1 && !ready[i]) return; // didn't load: skip it if (i === n - 1 && !place()) return; const to = i < n - 1 ? "inset(0px)" : aim ? aim.mask : ""; el.style.clipPath = to; run(el, [{ clipPath: i < n - 1 ? "inset(100% 0px 0px 0px)" : aim ? aim.from : "" }, { clipPath: to }], t.wipe); if (el.firstElementChild) { run(el.firstElementChild, [{ transform: `scale(${t.wipe.zoom})` }, { transform: "none" }], t.wipe); } next = elapsed + (value >= 100 ? t.gap : Math.max(t.gap, t.first * Math.pow(t.pace, i))); if (shot >= n) settled = elapsed + t.wipe.duration * t.settled; } else if (glyph < glyphs.length) { const due = t.letters.from + ((t.letters.to - t.letters.from) * glyph) / Math.max(1, glyphs.length - 1); if (value < 100 && p < due) return; kern(); const el = glyphs[glyph++]; el.style.transform = "none"; run(el, [{ transform: t.hidden.glyph }, { transform: "none" }], t.letters); next = elapsed + t.gap; if (glyph >= glyphs.length) settled = elapsed + t.letters.duration * t.settled; } }; // The loader's background clears over the end of the landing, so wherever // it differs from the page's, it eases into it instead of cutting. const clear = () => { if (!cover) return; run( cover, [{ backgroundColor: getComputedStyle(cover).backgroundColor }, { backgroundColor: "transparent" }], { duration: t.grow.duration * t.grow.clear, ease: t.out.ease }, t.grow.duration * (1 - t.grow.clear), "both", ); }; const grow = () => { phase = 2; cancelAnimationFrame(frame); // Measured again: fonts or a resize may have moved the target since. if (n && flier && box && place() && aim && aim.to) { box.style.visibility = "hidden"; // covered right now; it would show around the image as it moves const from = { transform: flier.style.transform, clipPath: flier.style.clipPath }; flier.style.transform = "none"; flier.style.clipPath = `inset(0px round ${aim.to.r}px)`; run(flier, [from, { transform: "none", clipPath: flier.style.clipPath }], t.grow).finished.then(reveal, () => {}); clear(); return; } const found = mark && glyphs.length ? target() : null; if (mark && found) { kern(); // The mark scales to the target's width, centered on it. Lines center // their glyphs, so centers line up whatever the line heights. It scales // from its own center, so it flies in a straight line. const letters = spot(word || glyphs[0]); const own = spot(mark); const to = found.spot; const s = to.w / letters.w; const ox = own.x + own.w / 2; const oy = own.y + own.h / 2; const x = to.x + to.w / 2 - ox - s * (letters.x + letters.w / 2 - ox); const y = to.y + to.h / 2 - oy - s * (letters.y + letters.h / 2 - oy); mark.style.transform = `translate(${x}px, ${y}px) scale(${s})`; run(mark, [{ transform: "none" }, { transform: mark.style.transform }], t.grow).finished.then(reveal, () => {}); clear(); return; } // Nowhere to land: the loader fades into the page. if (cover) run(cover, [{ opacity: 1 }, { opacity: 0 }], t.out, 0, "forwards").finished.then(reveal, () => {}); else reveal(); }; const settle = () => { phase = 4; state("done"); anims.forEach((a) => a.cancel()); anims = []; }; // The loader is gone. Each line in view rises, a stagger apart in reading // order; lines dropping from above, like a nav, keep their own count. const reveal = () => { if (phase > 2) return; phase = 3; release(); const v = view(); const rising: Animation[] = []; let below = 0; let above = 0; root.querySelectorAll<HTMLElement>("[data-intro-loader-line]").forEach((mask) => { const line = mask.firstElementChild; if (!line || (cover && cover.contains(mask))) return; const r = mask.getBoundingClientRect(); if (r.bottom < v.top || r.top > v.bottom || r.right < v.left || r.left > v.right) return; const up = mask.getAttribute("data-intro-loader-line") !== "above"; const delay = (up ? below++ : above++) * t.rise.stagger; rising.push( run(line, [{ transform: up ? t.hidden.below : t.hidden.above }, { transform: "none" }], t.rise, delay), ); }); state("reveal"); finish(); Promise.all(rising.map((a) => a.finished)).then(settle, () => {}); }; // Skipped: the page is there, at rest, as the loader fades off it. const out = () => { if (phase > 2) return; phase = 3; cancelAnimationFrame(frame); release(); state("out"); finish(); if (!cover) return settle(); cover.style.pointerEvents = "none"; run(cover, [{ opacity: getComputedStyle(cover).opacity }, { opacity: 0 }], t.out, 0, "forwards").finished.then( settle, () => {}, ); }; const tick = (now: number) => { frame = requestAnimationFrame(tick); find(); const dt = last ? Math.min(now - last, 500) / 1000 : 0; last = now; if (!started || !cover) return; if (!marked && o.once) { try { sessionStorage.setItem(o.key, "1"); } catch {} } marked = true; elapsed += dt; // Reduced motion: no loader, only a quick crossfade into the page. if (reduce) { if (elapsed < t.fade.delay) return; cancelAnimationFrame(frame); phase = 3; finish(); run(cover, [{ opacity: 1 }, { opacity: 0 }], t.fade, 0, "forwards").finished.then(settle, () => {}); return; } enter(); if (parsed && !scanned) scan(); if (phase === 0) count(); if (phase < 2) pace(); if (phase === 1 && shot >= n && glyph >= glyphs.length && elapsed >= Math.max(landed, settled) + t.breath) { grow(); } }; frame = requestAnimationFrame(tick); return handle; } const subscribe = () => () => {}; type Store = { get: () => boolean; set: () => void; subscribe: (listener: () => void) => () => void; }; function createStore(): Store { let done = false; let listeners: Array<() => void> = []; return { get: () => done, set() { if (done) return; done = true; listeners.forEach((l) => l()); }, subscribe(listener) { listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; }, }; } const IntroContext = React.createContext<Store | null>(null); /** * True once the loader has handed off to the page, or straight away when it's * skipped. False while rendering on the server. Start a hero's own entrance * with it. Outside an IntroLoader it's always true. */ export function useIntroDone() { const store = React.useContext(IntroContext); return React.useSyncExternalStore( store ? store.subscribe : subscribe, () => (store ? store.get() : true), () => !store, ); } const CORNERS: Record<Corner, string> = { "top-left": "top-0 left-0", "top-right": "top-0 right-0 text-right", "bottom-left": "bottom-0 left-0", "bottom-right": "bottom-0 right-0 text-right", }; export interface IntroLoaderProps extends React.HTMLAttributes<HTMLDivElement> { /** * The page. Wrap where the last image lands in IntroLoaderTarget, and the * lines that rise in after it in IntroLoaderLine. */ children?: React.ReactNode; /** The flipbook, in order. The last one lands on the target, so make it the image the target shows. */ images?: string[]; /** * Instead of images, a mark that rises letter by letter and lands on the * target, like a wordmark flying into the nav. Set it in the same type as * the target. A string is split into letters; anything else rises whole. */ mark?: React.ReactNode; /** Your name or logo, in the loader's top-left corner. Set it where the page's logo sits. */ brand?: React.ReactNode; /** Shown in place of the number, like a season. It rises in and leaves at 100 like the number. */ label?: React.ReactNode; /** The corner the number sits in, or false for none. */ counter?: Corner | false; /** Shortest the count can take, in seconds, even when everything is already loaded. */ minDuration?: number; /** Longest it waits for loading, in seconds, before landing anyway. */ maxDuration?: number; /** Play once per browser session, so reloads and the back button go straight to the page. */ once?: boolean; /** sessionStorage key that remembers it played. */ storageKey?: string; /** A click or any key goes straight to the page. */ skippable?: boolean; /** Cover this element instead of the screen, without locking the page's scroll. Starts once it's on screen. */ contained?: boolean; /** Called as the loader hands off to the page, or right away when it's skipped. */ onComplete?: () => void; /** Classes for the loader, such as its padding, font and color. Its background defaults to the page's. */ overlayClassName?: string; /** Classes for the number or label, such as its size. */ counterClassName?: string; /** Classes for the center frame the images play in, such as its size, shape and corners. */ frameClassName?: string; /** Classes for the mark, such as its font and size. */ markClassName?: string; } /** * A full-screen intro that counts to 100 as the page loads while a flipbook of * images plays in a small frame, then grows the last image into its place on * the page. Reads its props once, on mount; change its `key` to play it again. */ export function IntroLoader({ children, images = [], mark, brand, label, counter = "bottom-left", minDuration = 1.6, maxDuration = 4, once = true, storageKey = "intro-loader", skippable = true, contained = false, onComplete, overlayClassName, counterClassName, frameClassName, markClassName, className, nonce, ...props }: IntroLoaderProps) { const ref = React.useRef<HTMLDivElement>(null); // True while rendering on the server and hydrating that HTML, where the // inline script has already run. False when mounting on the client. const hydrating = React.useSyncExternalStore(subscribe, () => false, () => true); const [server] = React.useState(hydrating); const [store] = React.useState(createStore); const plan: Plan = { once, key: storageKey, min: minDuration, max: maxDuration, skippable, contained, skip: false, images, t: INTRO, }; const planRef = React.useRef(plan); const completeRef = React.useRef(onComplete); React.useLayoutEffect(() => { completeRef.current = onComplete; }); React.useLayoutEffect(() => { const root = ref.current as LoaderRoot | null; if (!root) return; // Server HTML without a handle means the script was blocked, by a Content // Security Policy without this nonce. Skip rather than cover a page // that's already been seen. const handle = root.__introLoader ?? introLoader(root, { ...planRef.current, skip: server }); const off = handle.on(() => { store.set(); completeRef.current?.(); }); return () => { off(); // Strict Mode runs effects twice on a node that stays in the page. queueMicrotask(() => { if (!root.isConnected) handle.destroy(); }); }; }, [server, store]); return ( <IntroContext.Provider value={store}> <div ref={ref} suppressHydrationWarning className={cn(contained && "relative isolate", className)} {...props} > {server ? ( <script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{ __html: `(${introLoader})(document.currentScript.parentElement,${JSON.stringify(plan).replace(/</g, "\\u003c")})`, }} /> ) : null} <div data-intro-loader-cover="" aria-hidden suppressHydrationWarning className={cn( "@container inset-0 z-[100] hidden overflow-hidden bg-[var(--intro-loader-bg,Canvas)] p-5 select-none in-data-[intro-loader=fade]:block in-data-[intro-loader=out]:block in-data-[intro-loader=play]:block sm:p-8", contained ? "absolute" : "fixed", overlayClassName, )} > <div className="relative size-full"> {brand ? ( <div className="absolute top-0 left-0"> <Riser>{brand}</Riser> </div> ) : null} {counter ? ( <div className={cn( "absolute text-[clamp(4rem,16cqw,10rem)] leading-none font-medium tracking-[-0.06em]", CORNERS[counter], counterClassName, )} > <Riser counter>{label ?? <Digits />}</Riser> </div> ) : null} </div> <div className="absolute inset-0 grid place-items-center"> {images.length ? ( <div data-intro-loader-frame="" suppressHydrationWarning className={cn( "relative aspect-[4/5] w-[clamp(7.5rem,18cqw,12rem)] overflow-clip rounded-sm", frameClassName, )} > {images.slice(0, -1).map((src, i) => ( <div key={i} data-intro-loader-shot="" suppressHydrationWarning className="absolute inset-0 [clip-path:inset(100%_0_0_0)]" > <Shot src={src} className="size-full object-cover" /> </div> ))} </div> ) : mark != null ? ( <Mark mark={mark} className={markClassName} /> ) : null} </div> {images.length ? ( <div data-intro-loader-flier="" suppressHydrationWarning className="absolute top-0 left-0 origin-top-left [clip-path:inset(100%_0_0_0)]" > <Shot src={images[images.length - 1]} className="block size-full object-cover" /> </div> ) : null} </div> {children} </div> </IntroContext.Provider> ); } /** * The loader's images carry their address in data-src. The script fills in * src only when it plays, so a visitor who's seen it doesn't download them. */ function Shot({ src, className }: { src: string; className?: string }) { return <img data-src={src} alt="" draggable={false} suppressHydrationWarning className={className} />; } /** * Rises out of a mask on the first paint. The padding keeps descenders inside * the mask; the negative margin takes the room back. It travels well past the * mask, so figures that hang outside their line, like old-style ones, clear it. */ function Riser({ counter, children }: { counter?: boolean; children: React.ReactNode }) { return ( <span className="-my-[0.1em] block overflow-y-clip py-[0.1em]"> <span data-intro-loader-in="" data-intro-loader-counter={counter ? "" : undefined} suppressHydrationWarning className="block [transform:translateY(140%)]" > {children} </span> </span> ); } const STRIP = Array.from({ length: 20 }, (_, i) => i % 10); /** * Three fixed-width digits, each a strip of 0-9 twice that rolls behind a * one-digit mask. The line height is set here, where a size passed in can't * merge it away: the strips' steps depend on it. */ function Digits() { return ( <span className="flex leading-[0.8] tabular-nums"> {[0, 1, 2].map((place) => ( <span key={place} className="block h-[0.8em] overflow-y-clip"> <span data-intro-loader-digit="" suppressHydrationWarning className="block"> {STRIP.map((digit, i) => ( <span key={i} className="block h-[0.8em]"> {digit} </span> ))} </span> </span> ))} </span> ); } function Mark({ mark, className }: { mark: React.ReactNode; className?: string }) { const word = typeof mark === "string" ? mark : null; const glyphs = word === null ? [mark] : Array.from(word); return ( <span data-intro-loader-mark="" suppressHydrationWarning className={cn( "relative flex text-[clamp(4rem,19cqw,14rem)] font-semibold tracking-[-0.045em] whitespace-pre", className, )} > {glyphs.map((glyph, i) => ( <span key={i} suppressHydrationWarning className="-my-[0.1em] block overflow-y-clip py-[0.1em] leading-none"> <span data-intro-loader-glyph="" suppressHydrationWarning className="block [transform:translateY(115%)]"> {glyph} </span> </span> ))} {/* The word set whole, for where each letter falls once kerned. */} {word !== null ? ( <span data-intro-loader-word="" className="invisible absolute top-0 left-0 leading-none"> {word} </span> ) : null} </span> ); } export interface IntroLoaderTargetProps extends React.HTMLAttributes<HTMLSpanElement> { /** What the last image lands on: an image, a video, a logo. Make it look like the last image or the mark. */ children?: React.ReactNode; } /** * Marks where the loader's last image lands. It adds no box of its own, so it * can wrap anything, anywhere; the loader measures its first child. It stays * hidden until the image lands on it, so it never shows doubled under it. */ export function IntroLoaderTarget({ className, children, ...props }: IntroLoaderTargetProps) { return ( <span data-intro-loader-target="" className={cn("contents in-data-[intro-loader=play]:invisible", className)} {...props} > {children} </span> ); } export interface IntroLoaderLineProps extends React.HTMLAttributes<HTMLSpanElement> { /** Rise from below, like a headline, or drop from above, like a nav. */ from?: "below" | "above"; children?: React.ReactNode; } /** * A line of the page that rises out of a clipped mask once the loader hands * off. Block by default; it can hold any element, not only text. */ export function IntroLoaderLine({ from = "below", className, children, ...props }: IntroLoaderLineProps) { return ( <span data-intro-loader-line={from} // The padding keeps descenders and accents inside the mask; the // negative margin takes the room back. It only clips while it plays, // so focus rings and shadows are whole afterwards. className={cn( "-my-[0.12em] block py-[0.12em] in-data-[intro-loader=play]:overflow-y-clip in-data-[intro-loader=reveal]:overflow-y-clip", className, )} {...props} > <span className={cn( "block", from === "above" ? "in-data-[intro-loader=play]:[transform:translateY(-115%)]" : "in-data-[intro-loader=play]:[transform:translateY(115%)]", )} > {children} </span> </span> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Intro Loader. Each one installs whole, with the component included.
Lookbook
A season label instead of a number, and a tall frame for the looks. The last look lands as the half of the page beside the headline.
"use client";
import * as React from "react";
import { IntroLoader, IntroLoaderLine, IntroLoaderTarget } from "@/components/ui/intro-loader";
const look = (id: string, w: number, h: number) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&h=${h}&q=80`;
const hero = look("1581044777550-4cfa60707c03", 1100, 1400);
const looks = [
look("1515886657613-9f3515b0c78f", 500, 750),
look("1509631179647-0177331693ae", 500, 750),
look("1529139574466-a303027c1d8b", 500, 750),
look("1496747611176-843222e1e57c", 500, 750),
hero,
];
// A season label in place of the number, a tall frame, and the last look
// landing as the half of the page beside the headline.
export function IntroLoaderLookbookDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-stone-200 bg-[#efebe4] text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-100">
<BrowserBar url="maisonlune.com/ss27" run={run} onReload={() => setRun((r) => r + 1)} />
<IntroLoader
key={run}
contained
once={false}
images={looks}
brand={<span className="font-serif text-lg leading-6 italic">Maison Lune</span>}
label="SS27"
counterClassName="font-serif text-[clamp(3.5rem,13cqw,8rem)] font-normal tracking-[-0.02em] italic"
frameClassName="aspect-[2/3] w-[clamp(7.5rem,17cqw,11rem)] rounded-none"
className="@container min-h-0 flex-1"
>
{/* Stacked on narrow screens; the look beside the headline on wide ones. */}
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)_auto] @2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] @2xl:grid-rows-[auto_minmax(0,1fr)]">
<header className="flex items-start justify-between gap-4 px-5 pt-5 sm:px-8 sm:pt-8">
<span className="font-serif text-lg leading-6 italic">Maison Lune</span>
<nav className="flex gap-5 text-[11px] leading-6 tracking-[0.16em] uppercase">
{["Shop", "Lookbook", "Stores"].map((item) => (
<IntroLoaderLine key={item} from="above" className={item === "Stores" ? "hidden @sm:block" : undefined}>
<a
href={`#${item.toLowerCase()}`}
className="text-stone-600 transition-colors duration-150 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
>
{item}
</a>
</IntroLoaderLine>
))}
</nav>
</header>
<div className="relative mt-5 min-h-0 @2xl:col-start-2 @2xl:row-span-2 @2xl:row-start-1 @2xl:mt-0">
<IntroLoaderTarget>
<img
src={hero}
alt="A woman in a pink ruffled dress standing in a dry summer field"
className="absolute inset-0 size-full object-cover"
/>
</IntroLoaderTarget>
</div>
<div className="min-w-0 px-5 pt-5 pb-6 sm:px-8 sm:pb-8 @2xl:col-start-1 @2xl:row-start-2 @2xl:self-end">
<IntroLoaderLine className="text-[11px] tracking-[0.2em] text-stone-500 uppercase">
Spring / Summer 2027
</IntroLoaderLine>
<h1 className="mt-3 font-serif text-[clamp(2.5rem,9.5cqw,7.5rem)] leading-[0.9] tracking-[-0.035em] @2xl:mt-4">
<IntroLoaderLine>Summer,</IntroLoaderLine>
<IntroLoaderLine className="italic">undone.</IntroLoaderLine>
</h1>
<IntroLoaderLine className="mt-5 hidden max-w-[17rem] text-sm leading-relaxed text-pretty text-stone-600 @2xl:block dark:text-stone-400">
Washed linen, raw silk and cotton voile, cut loose for long days by the water.
</IntroLoaderLine>
<IntroLoaderLine className="mt-4 @2xl:mt-6">
<a
href="#shop"
className="group inline-flex items-center gap-2 text-sm font-medium underline decoration-stone-400 underline-offset-4 transition-colors duration-150 hover:decoration-current"
>
Shop the collection
<span aria-hidden className="transition-transform duration-200 group-hover:translate-x-0.5">
→
</span>
</a>
</IntroLoaderLine>
</div>
</div>
</IntroLoader>
</div>
);
}
function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
return (
<div className="flex h-10 shrink-0 items-center gap-3 border-b border-stone-200 bg-white/60 px-3 dark:border-stone-800 dark:bg-stone-900/60">
<div aria-hidden className="flex gap-1.5">
{[0, 1, 2].map((dot) => (
<span key={dot} className="size-2.5 rounded-full bg-stone-300 dark:bg-stone-700" />
))}
</div>
<button
type="button"
onClick={onReload}
aria-label="Reload to play it again"
title="Play again"
className="grid size-7 place-items-center rounded-md text-stone-500 transition-[background-color,color,scale] duration-150 hover:bg-stone-200/70 hover:text-stone-900 active:scale-[0.94] dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ rotate: `${run * 360}deg` }}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</svg>
</button>
<span className="mx-auto truncate rounded-md bg-stone-100 px-3 py-1 font-mono text-[11px] text-stone-500 dark:bg-stone-800/80 dark:text-stone-400">
{url}
</span>
<span aria-hidden className="w-[4.75rem]" />
</div>
);
}
Wordmark
No images. The wordmark rises a letter at a time as the count runs, then shrinks and flies into the logo's spot in the header. The target can be anything.
Patient capital forcompanies that makereal things.
- €420mCommitted since 2019
- 38Companies backed
- 11 yrsAverage hold
"use client";
import * as React from "react";
import { IntroLoader, IntroLoaderLine, IntroLoaderTarget } from "@/components/ui/intro-loader";
const stats = [
{ value: "€420m", label: "Committed since 2019" },
{ value: "38", label: "Companies backed" },
{ value: "11 yrs", label: "Average hold" },
];
// No photography: the wordmark rises letter by letter as the count runs, then
// flies into the logo's spot in the header. The mark and the logo share their
// type, so it lands as the logo.
export function IntroLoaderWordmarkDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[#10291f] text-[#ece6d8] dark:border-zinc-800">
<BrowserBar url="verso.capital" run={run} onReload={() => setRun((r) => r + 1)} />
<IntroLoader
key={run}
contained
once={false}
mark="Verso"
markClassName="font-serif text-[clamp(5rem,27cqw,17rem)] font-normal tracking-[-0.035em]"
counter="bottom-right"
counterClassName="text-[clamp(1.75rem,5cqw,3rem)] font-normal tracking-[-0.03em] text-[#ece6d8]/60"
className="@container flex min-h-0 flex-1 flex-col"
>
<header className="flex items-center justify-between gap-6 px-5 pt-5 sm:px-8 sm:pt-8">
<IntroLoaderTarget>
<span className="font-serif text-2xl leading-none tracking-[-0.035em]">Verso</span>
</IntroLoaderTarget>
<nav className="flex items-center gap-5 text-sm sm:gap-7">
{["Portfolio", "Approach"].map((item) => (
<IntroLoaderLine key={item} from="above" className="hidden @lg:block">
<a
href={`#${item.toLowerCase()}`}
className="text-[#ece6d8]/65 transition-colors duration-150 hover:text-[#ece6d8]"
>
{item}
</a>
</IntroLoaderLine>
))}
<IntroLoaderLine from="above">
<a
href="#pitch"
className="inline-flex h-9 items-center rounded-full bg-[#ece6d8] px-4 font-medium text-[#10291f] transition-[background-color,scale] duration-150 hover:bg-white active:scale-[0.97]"
>
Pitch us
</a>
</IntroLoaderLine>
</nav>
</header>
<div className="mt-auto px-5 pb-6 sm:px-8 sm:pb-8">
<h1 className="font-serif text-[clamp(2.5rem,7.5cqw,6rem)] leading-[0.95] tracking-[-0.03em]">
<IntroLoaderLine>Patient capital for</IntroLoaderLine>
<IntroLoaderLine>
companies that <span className="italic">make</span>
</IntroLoaderLine>
<IntroLoaderLine>real things.</IntroLoaderLine>
</h1>
<ul className="mt-8 grid grid-cols-3 gap-4 border-t border-[#ece6d8]/15 pt-5 sm:mt-10 sm:gap-8">
{stats.map((stat) => (
<li key={stat.label}>
<IntroLoaderLine className="font-serif text-[clamp(1.5rem,3.5cqw,2.25rem)] leading-none tabular-nums">
{stat.value}
</IntroLoaderLine>
<IntroLoaderLine className="mt-2 text-xs text-[#ece6d8]/55">{stat.label}</IntroLoaderLine>
</li>
))}
</ul>
</div>
</IntroLoader>
</div>
);
}
function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
return (
<div className="flex h-10 shrink-0 items-center gap-3 border-b border-white/10 bg-black/15 px-3">
<div aria-hidden className="flex gap-1.5">
{[0, 1, 2].map((dot) => (
<span key={dot} className="size-2.5 rounded-full bg-white/15" />
))}
</div>
<button
type="button"
onClick={onReload}
aria-label="Reload to play it again"
title="Play again"
className="grid size-7 place-items-center rounded-md text-[#ece6d8]/60 transition-[background-color,color,scale] duration-150 hover:bg-white/10 hover:text-[#ece6d8] active:scale-[0.94]"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ rotate: `${run * 360}deg` }}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</svg>
</button>
<span className="mx-auto truncate rounded-md bg-white/10 px-3 py-1 font-mono text-[11px] text-[#ece6d8]/60">
{url}
</span>
<span aria-hidden className="w-[4.75rem]" />
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | The page, rendered underneath the whole time. Wrap where the last image lands in IntroLoaderTarget, and the lines that rise in after the handoff in IntroLoaderLine (from="above" drops them in, for a nav). useIntroDone() turns true at the handoff, for anything that makes its own entrance. |
images | string[] | None | The flipbook, in order. Each wipes up over the last, a little sooner each time. The last one lands on the target, so make it the image the target shows, at the same address. Four or more good images read best. |
mark | React.ReactNode | None | Instead of images: a mark that rises letter by letter with the count and flies onto the target, like a wordmark landing in the nav. Set it in the same type as the target. A string is split into letters, kerned as the whole word; anything else rises whole. |
brand | React.ReactNode | None | Your name or logo, in the loader's top-left corner. It stays put through the handoff, so set it like the logo on the page and in the same spot, and the page's logo takes its place. |
counter | "bottom-left" | "bottom-right" | "top-left" | "top-right" | false | "bottom-left" | The corner the number sits in, or false for none. The digits are fixed-width and each rolls into place; at 100 the number slides up out of view. |
label | React.ReactNode | None | Shown in place of the number, like a season. It rises in and leaves at 100 like the number does. |
minDuration | number | 1.6 | Shortest the count can take, in seconds, even when everything has already loaded, so it doesn't flash past on a fast connection. The whole intro runs about 1.3 seconds longer. |
maxDuration | number | 4 | Longest it waits for the fonts, the images and the page's images in view, in seconds, before landing anyway. While it waits, the count creeps on its own, so it never sits at 99. |
once | boolean | true | Play once per browser session. Reloads, the back button and coming back to the page go straight to it. Arriving by a link to a section (/#work) always does. |
storageKey | string | "intro-loader" | The sessionStorage key that remembers it played. |
skippable | boolean | true | A click or any key goes straight to the page. Modifier keys don't, so a screenshot shortcut doesn't skip the moment it's trying to catch. |
onComplete | () => void | None | Called as the loader hands off to the page, or straight away when it's skipped. |
contained | boolean | false | Cover this element instead of the screen, and leave the page's scroll alone. It starts once it's on screen. For a single section, or a preview like the ones here. |
overlayClassName | string | None | Classes for the loader, such as its padding, font and color. Its background is the page's own by default, read from the nearest background behind it, so nothing shows a seam at the handoff. |
counterClassName | string | None | Classes for the number or label, such as its size and font. |
frameClassName | string | "aspect-[4/5] rounded-sm" | Classes for the center frame the images play in: its width, aspect ratio and corners. |
markClassName | string | None | Classes for the mark, such as its font, weight and size. Match the target's. |
className | string | None | Classes for the element that wraps the page. Other props pass through to it. With reduced motion, there's no loader: the page crossfades in. |
nonce | string | None | Your Content Security Policy's nonce, for the small inline script that puts the loader up before the page is drawn. If the script is blocked, the loader is skipped. |
childrenType
React.ReactNodeThe page, rendered underneath the whole time. Wrap where the last image lands in
IntroLoaderTarget, and the lines that rise in after the handoff inIntroLoaderLine(from="above"drops them in, for a nav).useIntroDone()turns true at the handoff, for anything that makes its own entrance.imagesType
string[]The flipbook, in order. Each wipes up over the last, a little sooner each time. The last one lands on the target, so make it the image the target shows, at the same address. Four or more good images read best.
markType
React.ReactNodeInstead of images: a mark that rises letter by letter with the count and flies onto the target, like a wordmark landing in the nav. Set it in the same type as the target. A string is split into letters, kerned as the whole word; anything else rises whole.
brandType
React.ReactNodeYour name or logo, in the loader's top-left corner. It stays put through the handoff, so set it like the logo on the page and in the same spot, and the page's logo takes its place.
counterType
"bottom-left" | "bottom-right" | "top-left" | "top-right" | falseDefault"bottom-left"The corner the number sits in, or
falsefor none. The digits are fixed-width and each rolls into place; at 100 the number slides up out of view.labelType
React.ReactNodeShown in place of the number, like a season. It rises in and leaves at 100 like the number does.
minDurationType
numberDefault1.6Shortest the count can take, in seconds, even when everything has already loaded, so it doesn't flash past on a fast connection. The whole intro runs about 1.3 seconds longer.
maxDurationType
numberDefault4Longest it waits for the fonts, the images and the page's images in view, in seconds, before landing anyway. While it waits, the count creeps on its own, so it never sits at 99.
onceType
booleanDefaulttruePlay once per browser session. Reloads, the back button and coming back to the page go straight to it. Arriving by a link to a section (
/#work) always does.storageKeyType
stringDefault"intro-loader"The sessionStorage key that remembers it played.
skippableType
booleanDefaulttrueA click or any key goes straight to the page. Modifier keys don't, so a screenshot shortcut doesn't skip the moment it's trying to catch.
onCompleteType
() => voidCalled as the loader hands off to the page, or straight away when it's skipped.
containedType
booleanDefaultfalseCover this element instead of the screen, and leave the page's scroll alone. It starts once it's on screen. For a single section, or a preview like the ones here.
overlayClassNameType
stringClasses for the loader, such as its padding, font and color. Its background is the page's own by default, read from the nearest background behind it, so nothing shows a seam at the handoff.
counterClassNameType
stringClasses for the number or label, such as its size and font.
frameClassNameType
stringDefault"aspect-[4/5] rounded-sm"Classes for the center frame the images play in: its width, aspect ratio and corners.
markClassNameType
stringClasses for the mark, such as its font, weight and size. Match the target's.
classNameType
stringClasses for the element that wraps the page. Other props pass through to it. With reduced motion, there's no loader: the page crossfades in.
nonceType
stringYour Content Security Policy's nonce, for the small inline script that puts the loader up before the page is drawn. If the script is blocked, the loader is skipped.