Skip to content

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.json

From the Catchup template, see it in context.

Harbour Studio

Brand and web design for people who make things

Installation

$ npx shadcn@latest add https://framebrick.co/r/intro-loader.json

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

PropTypeDefaultDescription
imagesReactNode[]—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.
markReactNode—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.
delaynumber0.1Seconds before the first photo starts.
stepnumber0.3Seconds between one photo and the next, the mark after the last photo, and the cover after everything has left.
holdnumber1Seconds the window and the mark hold before they leave.
windowWidthnumber268Width of the window, in px.
windowHeightnumber200Height of the window, in px. Photos come from, and go to, 150 px beyond it.
gapnumber20Space 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.
storageKeystring"intro-loader"The key a visit is remembered under, so two loaders on one site keep apart.
classNamestring—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.

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy