Counting Preloader with Split Shutter Reveal

A quiet, editorial preloader. A pale cover hides the page while a hairline progress bar fills and a small counter climbs from 00 / 12 to 12 / 12. It holds on the full count for a moment, the counter fades, then the cover splits along the middle: the top half slides up, the bottom half slides down, and a dark photography portfolio shows through the widening gap. Timing runs in four named phases, so it is easy to connect to real loading. It plays when scrolled into view and a Replay button starts it again. The page underneath stacks into a single column on phones.

Code

// Adapted from Animata (https://animata.design/docs/preloader/split-reveal)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

const TOTAL_ASSETS = 12; // what the counter counts to
const ASSET_MS = 105; // demo only: time each pretend asset takes
const HOLD_MS = 240; // pause on the full count
const FADE_MS = 280; // counter fades before the shutters move
const REVEAL_S = 0.85; // shutter travel time

const frames = [
  { title: "Salt flats, dawn", tone: "from-sky-300 via-indigo-400 to-violet-600", span: "row-span-2" },
  { title: "Night market", tone: "from-rose-400 via-red-500 to-orange-500", span: "" },
  { title: "North face", tone: "from-emerald-300 via-teal-500 to-cyan-700", span: "" },
];

type Phase = "loading" | "fade" | "reveal" | "done";

export default function CountingPreloaderWithSplitShutterReveal() {
  const reduce = useReducedMotion();
  const frameRef = useRef<HTMLDivElement>(null);
  const inView = useInView(frameRef, { once: true, amount: 0.3 });
  const [phase, setPhase] = useState<Phase>("loading");
  const [loaded, setLoaded] = useState(0);
  const [run, setRun] = useState(0);

  // Stand-in for real loading: one asset finishes every ASSET_MS. Swap this for your own progress.
  useEffect(() => {
    if (!inView) return;
    if (reduce) {
      setLoaded(TOTAL_ASSETS);
      setPhase("done");
      return;
    }
    setLoaded(0);
    setPhase("loading");
    let count = 0;
    const timer = window.setInterval(() => {
      count += 1;
      setLoaded(count);
      if (count >= TOTAL_ASSETS) window.clearInterval(timer);
    }, ASSET_MS);
    return () => window.clearInterval(timer);
  }, [inView, reduce, run]);

  // Full count, then hold, fade the counter, open the shutters, finish.
  useEffect(() => {
    if (phase === "done" || loaded < TOTAL_ASSETS) return;
    const wait = phase === "loading" ? HOLD_MS : phase === "fade" ? FADE_MS : REVEAL_S * 1000;
    const next: Phase = phase === "loading" ? "fade" : phase === "fade" ? "reveal" : "done";
    const timer = window.setTimeout(() => setPhase(next), wait);
    return () => window.clearTimeout(timer);
  }, [loaded, phase]);

  const opened = phase === "reveal" || phase === "done";
  const shutter = { duration: REVEAL_S, ease: [0.76, 0, 0.24, 1] as const };
  const pad = (value: number) => String(value).padStart(2, "0");

  return (
    <section className="bg-neutral-900 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-neutral-950 ring-1 ring-neutral-800">
          {/* The page underneath. */}
          <div className="grid h-full gap-6 p-6 sm:grid-cols-2 sm:gap-8 sm:p-10">
            <div className="flex flex-col">
              <p className="text-sm font-semibold uppercase tracking-[0.25em] text-neutral-400">Ines Okafor, photographer</p>
              <h2 className="mt-4 text-4xl font-semibold leading-tight tracking-tight text-white sm:mt-6 sm:text-5xl">Slow pictures of fast places.</h2>
              <p className="mt-4 max-w-sm text-base leading-relaxed text-neutral-300">Twelve new prints from a year on the road, in editions of thirty.</p>
              <a
                href="#"
                className="mt-6 inline-flex h-11 w-fit items-center rounded-full bg-white px-6 text-sm font-semibold text-neutral-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 sm:mt-auto"
              >
                View the prints
              </a>
            </div>
            <ul className="grid min-h-0 grid-cols-2 grid-rows-2 gap-3">
              {frames.map((frame) => (
                <li key={frame.title} className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${frame.tone} ${frame.span}`}>
                  <span className="absolute bottom-3 left-3 text-xs font-semibold text-white">{frame.title}</span>
                </li>
              ))}
            </ul>
          </div>

          {/* The two shutters and the counter. Removed from the tree once the reveal has finished. */}
          {phase !== "done" ? (
            <div key={run} className={`absolute inset-0 ${phase === "loading" ? "" : "pointer-events-none"}`} role="status" aria-live="polite" aria-busy={phase === "loading"}>
              <motion.div className="absolute inset-x-0 top-0 h-1/2 bg-neutral-100" initial={false} animate={{ y: opened ? "-101%" : 0 }} transition={shutter} />
              <motion.div className="absolute inset-x-0 bottom-0 h-1/2 bg-neutral-100" initial={false} animate={{ y: opened ? "101%" : 0 }} transition={shutter} />
              <motion.div
                className="absolute inset-0 flex flex-col items-center justify-center"
                initial={false}
                animate={{ opacity: phase === "loading" ? 1 : 0 }}
                transition={{ duration: FADE_MS / 1000 }}
              >
                <span className="sr-only">
                  Loading, {loaded} of {TOTAL_ASSETS}
                </span>
                <span aria-hidden="true" className="text-xs font-semibold uppercase tracking-[0.4em] text-neutral-600">
                  Developing
                </span>
                <span aria-hidden="true" className="mt-5 block h-px w-44 overflow-hidden bg-neutral-300 sm:w-64">
                  <span className="block h-full origin-left bg-neutral-950 transition-transform duration-200 ease-out" style={{ transform: `scaleX(${loaded / TOTAL_ASSETS})` }} />
                </span>
                <span aria-hidden="true" className="mt-4 text-[11px] font-medium tabular-nums tracking-[0.2em] text-neutral-700">
                  {pad(loaded)}
                  <span className="text-neutral-400"> / </span>
                  {pad(TOTAL_ASSETS)}
                </span>
              </motion.div>
            </div>
          ) : null}

        </div>
        <div className="mt-4 flex justify-end">
          <button
            type="button"
            onClick={() => {
              setPhase("loading");
              setLoaded(0);
              setRun((current) => current + 1);
            }}
            className="inline-flex h-10 items-center gap-2 rounded-full bg-neutral-800 px-4 text-sm font-semibold text-white ring-1 ring-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
          >
            <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/counting-preloader-with-split-shutter-reveal.json

When to use it

How to customize it

Accessibility

Questions

How do I connect it to real image loading?

Create an `Image` object for each address you need, and in its `onload` and `onerror` handlers increase a count and call `setLoaded`. Set `TOTAL_ASSETS` to the length of your list. Count errors as finished too, so one broken image cannot hold the cover up for ever.

What are the four phases?

`loading` shows the counter. When the count is full it moves to `fade`, where the counter fades out. `reveal` slides the two halves away. `done` removes the cover. Each step is one timeout, chosen from the constants at the top of the file.

How do I make it cover the whole browser window?

Move the cover out of the frame and give it `fixed inset-0 z-50`. While the phase is not `done`, also set `overflow: hidden` on the body so the page cannot scroll behind it.

Why does the cover travel 101 percent instead of 100?

At exactly 100 percent a rounding error can leave a one pixel sliver of the cover at the edge of the frame. One extra percent guarantees it is fully out of view.

What happens if assets load instantly from cache?

The count jumps to full and the hold, fade and reveal still run, which takes a little over a second. To skip the intro for returning visitors, start the phase at `done` when your assets report as already complete.

Adapted from Split Reveal in Animata, used under its open-source licence. Licences and credits

Related: Vertical Tile Curtain Preloader That Drops from the Centre, Multi-Step Loading Sequence with Drawn Checkmarks, Six CSS Loaders: Orbit, Bars, Morph, Ring, Pulse and Shimmer.

More loaders or all free UI components. Start for free to have a full site built around it.

More in Loaders

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.

Product

Who It's For

Features

Resources

Company

View Sitemap