Intro Loader
A full-screen preloader: photos rise into a window, the logo joins them, then the cover lifts and uncovers the page.
$npx shadcn@latest add https://framebrick.co/r/intro-loader.jsonFrom the Catchup template, see it in context.
Harbour Studio
Brand and web design for people who make things
"use client";
import * as React from "react";
import { IntroLoader } from "@/components/ui/intro-loader";
// Demo photos from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
const photos = [
"/demo/slideshow-off-season.webp",
"/demo/slideshow-long-exposure.webp",
"/demo/slideshow-field-notes.webp",
"/demo/slideshow-harbour.webp",
];
/*
* In your app the loader is mounted once at the top of the page and covers the whole screen, once per browser session.
* Here it runs inside a box (position: relative, overflow: hidden, the cover at h-full) so the page around it stays put,
* with `once={false}` and a new key every 6.5 s, so the card shows the intro whenever you look.
*/
export default function IntroLoaderDemo() {
const [run, setRun] = React.useState(0);
React.useEffect(() => {
const timer = window.setInterval(() => setRun((n) => n + 1), 6500);
return () => window.clearInterval(timer);
}, []);
return (
<div className="relative h-72 w-full max-w-md overflow-hidden rounded-lg border bg-background">
<div className="flex size-full flex-col items-center justify-center gap-2 text-center">
<p className="text-3xl font-semibold tracking-tight">Harbour Studio</p>
<p className="text-sm text-muted-foreground">Brand and web design for people who make things</p>
</div>
<IntroLoader
key={run}
once={false}
className="h-full"
windowWidth={150}
windowHeight={112}
gap={14}
images={photos.map((src) => (
<img key={src} src={src} alt="" draggable={false} />
))}
mark={<span className="text-xl font-semibold tracking-tight">Harbour</span>}
/>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/intro-loader.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/intro-loader.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Intro Loader — a full-screen preloader (from the Catchup Home "Loader"). A cover in the foreground colour fills the first * screen. A window sits in the middle, and the photos rise into it one after another, each over the last. Then the mark * rises from below the screen to 20px under the window, and the window moves up half the pair's height to keep it centred. * After a hold the photos leave upwards, the mark flies off the top, and one step later the cover shrinks upwards and * uncovers the page, about 3.6s in all. Every move is Framer's tween T1: 1s, cubic-bezier(.59,.25,.12,1.17), which * overshoots about 3% and comes back. * * Mount it at the top of the page, not inside a positioned element: the cover is absolute, not fixed, so it scrolls * away with the page, which is not locked, and it never takes a click or a key (pointer-events none). Anything on the * page that animates in on load plays behind the cover, so give it a delay of about 2.6s or let the page simply be revealed. * * Whether it plays is decided before the first paint, by an inline script right after the cover: it plays once per browser * session (`once`), never for a visitor who asked for reduced motion, and never without JavaScript — the cover stays * `display: none` until the script shows it. The script puts `data-intro="play"` (or "done") on the cover itself, so * the page's own <html> is left alone; the cover carries suppressHydrationWarning because of that attribute. Arriving * by a client-side navigation no script runs, and the same decision is made in a layout effect, still before the paint. * * The moves are CSS transitions on transforms, driven by a few timers: nothing but the compositor draws the intro. Sizes * inside use container units (cqh), so it fills a screen or any box the same way. */ const ease = "cubic-bezier(.59,.25,.12,1.17)"; const tween = (delay = 0) => `transform 1s ${ease} ${delay}s`; export type IntroLoaderProps = { /** The photos that rise into the window, in the order they arrive: each lands over the one before, so the last is on top when they leave. */ images: React.ReactNode[]; /** The mark that rises under the window once the photos are in: a logo, a wordmark. It takes the colour of the page behind the cover. */ mark?: React.ReactNode; /** Seconds before the first photo starts. */ delay?: number; /** Seconds between one photo and the next, the mark after the last photo, and the cover after everything has left. */ step?: number; /** Seconds the window and the mark hold before they leave. */ hold?: number; /** Width of the window, in px. */ windowWidth?: number; /** Height of the window, in px. */ windowHeight?: number; /** Space between the window and the mark, in px. */ gap?: number; /** How often it plays: once per browser session, once ever, or on every page load. */ once?: "session" | "forever" | false; /** The key a visit is remembered under, so two loaders on one site keep apart. */ storageKey?: string; /** Classes for the cover: its colour, its layer (z-50, over a sticky header), its height (h-full inside a box). */ className?: string; }; const noop = () => () => {}; /* The decision, as the script makes it: play unless this visit has seen it or the visitor asked for reduced motion. */ function decide(once: IntroLoaderProps["once"], key: string) { let seen = false; try { if (once) seen = Boolean((once === "forever" ? localStorage : sessionStorage).getItem(key)); } catch { seen = true; } return !seen && !window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "play" : "done"; } function script(once: IntroLoaderProps["once"], key: string) { const seen = once ? `${once === "forever" ? "localStorage" : "sessionStorage"}.getItem(${JSON.stringify(key).replace(/</g, "\\u003c")})` : "false"; return `try{var e=document.currentScript.previousElementSibling;e.dataset.intro=!${seen}&&!matchMedia("(prefers-reduced-motion: reduce)").matches?"play":"done"}catch(x){}`; } export function IntroLoader({ images, mark, delay = 0.1, step = 0.3, hold = 1, windowWidth = 268, windowHeight = 200, gap = 20, once = "session", storageKey = "intro-loader", className, }: IntroLoaderProps) { const root = React.useRef<HTMLDivElement>(null); const markBox = React.useRef<HTMLDivElement>(null); /* 0 waiting, 1…n photo n has arrived, n + 1 the mark has joined, n + 2 everything leaves. */ const [stage, setStage] = React.useState(0); const [lift, setLift] = React.useState(0); const [finished, setFinished] = React.useState(false); /* The script is in the server's HTML and in the first client render, which has to match it; a cover that mounts on the client has none to run. */ const withScript = React.useSyncExternalStore(noop, () => false, () => true); const count = images.length; const hasMark = Boolean(mark); /* Before the first paint, so the page never shows for a moment before the cover. */ React.useLayoutEffect(() => { const element = root.current; if (!element) return; if (!element.dataset.intro) element.dataset.intro = decide(once, storageKey); if (element.dataset.intro !== "play") { setFinished(true); return; } if (once) { try { (once === "forever" ? localStorage : sessionStorage).setItem(storageKey, "1"); } catch {} } /* The window rises half of (mark + gap) when the mark joins, which keeps the pair centred. */ setLift(hasMark && markBox.current ? (markBox.current.offsetHeight + gap) / 2 : 0); setStage(0); const arrivals = Array.from({ length: count }, (_, index) => delay + index * step); const joinsAt = delay + count * step; const leavesAt = joinsAt + hold; /* The last time is the cover having shrunk (one step of delay and its second), with a little room, when it is taken away. */ const times = [...arrivals, joinsAt, leavesAt, leavesAt + step + 1.2]; const timers = times.map((time, index) => window.setTimeout(() => (index === times.length - 1 ? setFinished(true) : setStage(index + 1)), time * 1000)); return () => timers.forEach((timer) => window.clearTimeout(timer)); }, [count, hasMark, delay, step, hold, gap, once, storageKey]); if (finished) return null; const joined = stage >= count + 1; const leaving = stage >= count + 2; /* Photos come from, and go to, this far below and above the window. */ const travel = windowHeight + 150; /* The mark's top, below the middle, once it has joined; and how far it has to move to be off the bottom and off the top. */ const below = windowHeight / 2 - lift + gap; return ( <> <div ref={root} aria-hidden suppressHydrationWarning className={cn( "pointer-events-none absolute inset-x-0 top-0 z-50 hidden h-screen [container-type:size] overflow-hidden data-[intro=play]:block print:hidden!", className, )} > <div className="absolute inset-0 origin-top bg-foreground" style={{ transform: leaving ? "translateY(-120px) scaleY(.01)" : "translateY(0px) scaleY(1)", transition: tween(leaving ? step : 0) }} /> <div className="absolute inset-0 z-1 m-auto overflow-hidden" style={{ width: windowWidth, height: windowHeight, transform: `translateY(${joined && !leaving ? -lift : 0}px)`, transition: tween() }} > {images.map((image, index) => ( <div key={index} className="absolute inset-0 [&>*]:size-full [&>*]:object-cover" style={{ transform: `translateY(${leaving ? -travel : stage >= index + 1 ? 0 : travel}px)`, transition: tween() }} > {image} </div> ))} </div> {hasMark && ( <div ref={markBox} className="absolute inset-x-0 flex justify-center text-background" style={{ top: `calc(50% + ${below}px)`, transform: leaving ? `translateY(calc(-50cqh - 100px - ${below}px))` : joined ? "translateY(0px)" : `translateY(calc(50cqh + 58px - ${below}px))`, transition: tween(), }} > {mark} </div> )} </div> {withScript && <script dangerouslySetInnerHTML={{ __html: script(once, storageKey) }} />} </> ); }
Usage
import { IntroLoader } from "@/components/ui/intro-loader";
// at the top of the page (the home page, say), not inside a positioned element
<IntroLoader
images={works.map((work) => <img key={work.slug} src={work.image} alt="" />)}
mark={<Logo className="h-10 w-auto" />}
/>
// every visit instead of once per session, and a slower hold
<IntroLoader images={photos} mark={<Logo />} once={false} hold={1.5} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images | ReactNode[] | — | The photos that rise into the window, in the order they arrive: each lands over the one before, so the last is on top when they leave. |
| mark | ReactNode | — | The logo that joins under the window once the photos are in. It takes the page's background colour (text-background), so an SVG drawn with currentColor shows. |
| delay | number | 0.1 | Seconds before the first photo starts. |
| step | number | 0.3 | Seconds between one photo and the next, the mark after the last photo, and the cover after everything has left. |
| hold | number | 1 | Seconds the window and the mark hold before they leave. |
| windowWidth | number | 268 | Width of the window, in px. |
| windowHeight | number | 200 | Height of the window, in px. Photos come from, and go to, 150 px beyond it. |
| gap | number | 20 | Space between the window and the mark, in px. |
| once | "session" | "forever" | false | "session" | How often it plays: once per browser session (sessionStorage), once ever (localStorage), or on every page load. |
| storageKey | string | "intro-loader" | The key a visit is remembered under, so two loaders on one site keep apart. |
| className | string | — | Classes for the cover: its colour, its layer (z-50 by default; raise it above a header that sits higher) and its height (h-full to run it inside a box). |
- The sequence is Catchup's Home loader: the photos rise 0.3 s apart (the first after 0.1 s), the mark rises under the window and the window moves up half the pair's height to keep it centred, after a hold of 1 s the photos leave upwards and the mark flies off the top, and one step later the cover shrinks upwards, about 3.6 s in all. Every move is Framer's tween T1, 1 s on cubic-bezier(.59,.25,.12,1.17), which overshoots about 3% and comes back.
- Mount it at the top of the page, not inside a positioned element: the cover is absolute, not fixed, so it scrolls away with the page, the page is not locked, and it never takes a click or a key (pointer-events none). Anything on the page that animates in on load plays behind the cover: give it a delay of about 2.6 s, or let the page simply be revealed, as Catchup's hero is.
- Whether it plays is decided before the first paint, by an inline script right after the cover, so the page never shows for a moment first. The script writes data-intro="play" (or "done") on the cover itself, not on <html>, and the cover carries suppressHydrationWarning for that attribute. On a client-side navigation no script runs, and a layout effect makes the same decision, still before the paint.
- It plays once per browser session by default, which is the owner's ruling for Catchup (Framer plays the loader on every visit to Home). once="forever" remembers it in localStorage, and false plays it on every load. If the browser blocks storage it does not play.
- It never plays for a visitor who asked for reduced motion (Framer plays it anyway), and without JavaScript it never shows: the cover stays display: none until the script shows it, so a broken bundle cannot leave a black screen. It is hidden in print.
- No animation library: the moves are CSS transitions on transforms, started by a few timers, so the compositor draws the intro. Distances use container units (cqh: Chrome 105, Safari 16, Firefox 110), so the same intro fills a screen or any box. To run it inside a section or a card, give the box position: relative and overflow: hidden and the cover className="h-full", as this page does.
- The cover, the photos and the mark are aria-hidden, and nothing in it can take focus.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called IntroLoader — a full-screen preloader, a client component, no animation library.
- Props: images (ReactNode[]), mark (ReactNode), delay (0.1 s), step (0.3 s), hold (1 s), windowWidth (268), windowHeight (200), gap (20), once ("session" | "forever" | false, default "session"), storageKey ("intro-loader"), className.
- The cover is absolute inset-x-0 top-0 h-screen z-50 overflow-hidden pointer-events-none aria-hidden, [container-type:size], print:hidden!, and display none until it carries data-intro="play" (hidden data-[intro=play]:block). A bg-foreground layer fills it with transform-origin top. Give it suppressHydrationWarning.
- A window (absolute inset-0 m-auto z-1 overflow-hidden, windowWidth by windowHeight) holds the images, each absolute inset-0 with its child size-full object-cover; later images are on top. The mark is absolute inset-x-0, flex justify-center, text-background, at top calc(50% + windowHeight / 2 - lift + gap), where lift = (the mark's measured height + gap) / 2.
- Every move is a CSS transition on transform: 1s cubic-bezier(.59,.25,.12,1.17). Drive a stage counter with timers. Photo k arrives at delay + (k - 1) * step: translateY from windowHeight + 150 px to 0. The mark joins at delay + n * step: from translateY(calc(50cqh + 58px - offset)) to 0, and the window moves translateY(-lift), where offset is the mark's top below the middle. After hold, everything leaves: photos to -(windowHeight + 150) px, the mark to translateY(calc(-50cqh - 100px - offset)), the window back to 0, and the cover to translateY(-120px) scaleY(.01) with a transition delay of step. Unmount a little after the cover has gone.
- Decide before the first paint: an inline <script> placed right after the cover reads document.currentScript.previousElementSibling and sets dataset.intro to "play" unless the storage key (sessionStorage or localStorage) exists or matchMedia("(prefers-reduced-motion: reduce)") matches, all inside try/catch. Render that script only for the server render and hydration: useSyncExternalStore with a server snapshot of true and a client snapshot of false. A useLayoutEffect makes the same decision when the attribute is missing (client navigation), saves the key when it plays, measures the mark, and starts the timers; when the decision is "done" it unmounts the cover.














