A page intro made of tall dark tiles standing side by side over the content. While they are up, the site name and a counter running from 000 to 100 sit in the middle. When the count finishes the label fades and the tiles drop out of the bottom of the frame, the centre ones first and the edges last, uncovering a hotel landing page with a headline and three room cards. The number of tiles follows the width, so a phone gets about six and a desktop about seventeen. It plays when it scrolls into view and a Replay button runs it again.
// Adapted from Animata (https://animata.design/docs/preloader/vertical-tiles)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, useState } from "react";
import { animate, motion, useInView, useReducedMotion } from "framer-motion";
const MIN_TILE_WIDTH = 56; // tiles are at least this wide; the count follows the frame width
const HOLD = 1.0; // seconds the curtain stays up while the counter runs
const DROP = 0.55; // seconds each tile takes to fall
const STAGGER = 0.05; // extra delay per step away from the centre
const rooms = [
{ name: "Harbour suite", price: "£240", tone: "from-amber-200 to-orange-300" },
{ name: "Garden room", price: "£165", tone: "from-emerald-200 to-teal-300" },
{ name: "Attic studio", price: "£130", tone: "from-rose-200 to-pink-300" },
];
export default function VerticalTileCurtainPreloaderThatDropsFromTheCentre() {
const reduce = useReducedMotion();
const frameRef = useRef<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const inView = useInView(frameRef, { once: true, amount: 0.3 });
const [tiles, setTiles] = useState<{ id: number; order: number }[]>([]);
const [run, setRun] = useState(0);
const [loading, setLoading] = useState(true);
const measure = useCallback(() => {
const frame = frameRef.current;
if (!frame) return;
const count = Math.max(3, Math.floor(frame.offsetWidth / MIN_TILE_WIDTH));
const middle = (count - 1) / 2;
setTiles((current) => (current.length === count ? current : Array.from({ length: count }, (_, id) => ({ id, order: Math.abs(id - middle) }))));
}, []);
useEffect(() => {
measure();
const observer = new ResizeObserver(measure);
if (frameRef.current) observer.observe(frameRef.current);
return () => observer.disconnect();
}, [measure]);
// The counter runs to 100 while the curtain is up, then hands over to the tiles.
useEffect(() => {
if (!inView) return;
if (reduce) {
setLoading(false);
return;
}
setLoading(true);
const controls = animate(0, 100, {
duration: HOLD,
ease: [0.3, 0, 0.2, 1],
onUpdate: (value) => {
if (countRef.current) countRef.current.textContent = String(Math.round(value)).padStart(3, "0");
},
onComplete: () => setLoading(false),
});
return () => controls.stop();
}, [inView, reduce, run]);
const open = inView && !loading;
return (
<section className="bg-stone-200 px-4 py-10 sm:px-6 sm:py-14">
<div className="mx-auto max-w-5xl">
<div ref={frameRef} className="relative h-[540px] overflow-hidden rounded-3xl bg-orange-50 shadow-xl shadow-stone-400/40">
{/* The page underneath. */}
<div className="flex h-full flex-col px-6 py-6 sm:px-10 sm:py-8">
<div className="flex items-center justify-between">
<p className="text-sm font-bold uppercase tracking-[0.25em] text-stone-900">The Saltings</p>
<a href="#" className="inline-flex h-10 items-center rounded-full bg-stone-900 px-5 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2">
Check dates
</a>
</div>
<div className="mt-8 sm:mt-12">
<h2 className="max-w-xl text-4xl font-semibold leading-tight tracking-tight text-stone-900 sm:text-5xl">Nine rooms above the tide line.</h2>
<p className="mt-4 max-w-md text-base leading-relaxed text-stone-700">
A former customs house on the Norfolk coast. Breakfast until eleven, boots by the door, the sea from every window.
</p>
</div>
<ul className="mt-auto grid grid-cols-3 gap-3 sm:gap-5">
{rooms.map((room) => (
<li key={room.name}>
<div className={`h-24 rounded-2xl bg-gradient-to-br sm:h-36 ${room.tone}`} />
<p className="mt-2 truncate text-sm font-semibold text-stone-900">{room.name}</p>
<p className="text-sm text-stone-700">{room.price} a night</p>
</li>
))}
</ul>
</div>
{/* The curtain. A flat cover stands in until the tiles have been measured. */}
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
{tiles.length === 0 ? <div className="absolute inset-0 bg-stone-900" /> : null}
{tiles.map((tile) => (
<motion.div
key={`${run}-${tile.id}`}
className="absolute top-0 h-full bg-stone-900"
style={{ left: `${(tile.id * 100) / tiles.length}%`, width: `calc(${100 / tiles.length}% + 1px)` }}
initial={{ y: 0 }}
animate={{ y: open ? "101%" : 0 }}
transition={{ duration: reduce ? 0 : DROP, delay: reduce ? 0 : tile.order * STAGGER, ease: [0.45, 0, 0.55, 1] }}
/>
))}
</div>
<motion.div
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-orange-50"
initial={false}
animate={{ opacity: open ? 0 : 1, y: open ? 12 : 0 }}
transition={{ duration: 0.25 }}
role="status"
aria-live="polite"
>
<span className="sr-only">{open ? "Page loaded" : "Loading"}</span>
<span aria-hidden="true" className="text-sm font-bold uppercase tracking-[0.4em]">
The Saltings
</span>
<span ref={countRef} aria-hidden="true" className="mt-4 font-mono text-6xl font-light tabular-nums sm:text-7xl">
000
</span>
<span aria-hidden="true" className="mt-4 text-xs uppercase tracking-[0.3em] text-orange-200">
Opening the shutters
</span>
</motion.div>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => {
setLoading(true);
setRun((current) => current + 1);
}}
className="inline-flex h-10 items-center gap-2 rounded-full bg-white px-4 text-sm font-semibold text-stone-900 shadow-lg ring-1 ring-stone-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-600"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M4 10a6 6 0 1 0 2-4.5M4 3.5v3h3" />
</svg>
Replay
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/vertical-tile-curtain-preloader-that-drops-from-the-centre.json
Move the curtain and label layers into a `fixed inset-0 z-50` wrapper at the top of your layout and measure `window.innerWidth` instead of the frame. Replace the timed counter with your own ready signal, for example `document.fonts.ready` or the load event of your hero image, and set `loading` to false when it fires.
The tile count depends on the measured width, which is only known in the browser. Until that first measurement there are no tiles, so a plain cover of the same colour hides the page. Without it the content would flash before the curtain appears.
Percentage widths rarely land on whole pixels, which can leave hairline gaps between tiles. The extra pixel makes neighbours overlap slightly so the curtain looks solid.
No. framer-motion's `animate` function writes the number straight into the span through a ref on every frame. React only re-renders once, when the count completes.
Yes. Increase the `run` value on navigation. The tiles are keyed by it, so they remount in the covering position and the sequence starts again.
Adapted from Vertical Tiles in Animata, used under its open-source licence. Licences and credits
Related: Multi-Step Loading Sequence with Drawn Checkmarks, Six CSS Loaders: Orbit, Bars, Morph, Ring, Pulse and Shimmer, Wave Shimmer Loading Text with Export Progress.
More loaders or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.