Skip to content
Docs
Split Reveal

Split Reveal

Full-screen preloader that loads images, locks scroll, then opens from the center seam.

new
Loading...

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/preloader/split-reveal.json

The registry item installs the split-reveal/ modules, split-reveal.css, and shared hooks at hooks/use-lock-body.ts and hooks/use-prefers-reduced-motion.ts.

Manual

Copy shared hooks

"use client";
 
import { useEffect } from "react";
 
export function useLockBody(active = true) {
  useEffect(() => {
    if (!active) {
      return;
    }
 
    const html = document.documentElement;
    const body = document.body;
    const scrollY = window.scrollY;
 
    const previous = {
      htmlOverflow: html.style.overflow,
      bodyOverflow: body.style.overflow,
      bodyPosition: body.style.position,
      bodyTop: body.style.top,
      bodyLeft: body.style.left,
      bodyRight: body.style.right,
      bodyWidth: body.style.width,
      bodyTouchAction: body.style.touchAction,
    };
 
    html.style.overflow = "hidden";
    body.style.overflow = "hidden";
    body.style.position = "fixed";
    body.style.top = `-${scrollY}px`;
    body.style.left = "0";
    body.style.right = "0";
    body.style.width = "100%";
    body.style.touchAction = "none";
 
    return () => {
      html.style.overflow = previous.htmlOverflow;
      body.style.overflow = previous.bodyOverflow;
      body.style.position = previous.bodyPosition;
      body.style.top = previous.bodyTop;
      body.style.left = previous.bodyLeft;
      body.style.right = previous.bodyRight;
      body.style.width = previous.bodyWidth;
      body.style.touchAction = previous.bodyTouchAction;
      window.scrollTo(0, scrollY);
    };
  }, [active]);
}
"use client";
 
import { useSyncExternalStore } from "react";
 
function subscribeReducedMotion(callback: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  if (typeof mq.addEventListener === "function") {
    mq.addEventListener("change", callback);
    return () => mq.removeEventListener("change", callback);
  }
 
  const legacyMq = mq as MediaQueryList & {
    addListener: (listener: () => void) => void;
    removeListener: (listener: () => void) => void;
  };
  legacyMq.addListener(callback);
  return () => legacyMq.removeListener(callback);
}
 
function getReducedMotionSnapshot() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
 
export function usePrefersReducedMotion() {
  return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, () => false);
}

Run the following command

It will create split-reveal.tsx, the co-located split-reveal/ modules, and split-reveal.css inside components/animata/preloader.

mkdir -p components/animata/preloader/split-reveal && touch components/animata/preloader/split-reveal.tsx components/animata/preloader/split-reveal.css

Paste the code

@layer components {
  @keyframes split-reveal-shutter-top {
    from {
      transform: translate3d(0, 0, 0);
    }
 
    to {
      transform: translate3d(0, -100%, 0);
    }
  }
 
  @keyframes split-reveal-shutter-bottom {
    from {
      transform: translate3d(0, 0, 0);
    }
 
    to {
      transform: translate3d(0, 100%, 0);
    }
  }
 
  [data-split-reveal-overlay] [data-split-reveal-progress] {
    opacity: 1;
    transition: opacity var(--split-reveal-progress-fade) ease-out;
  }
 
  [data-split-reveal-overlay][data-phase="fade-ui"] [data-split-reveal-progress],
  [data-split-reveal-overlay][data-phase="reveal"] [data-split-reveal-progress] {
    opacity: 0;
  }
 
  [data-split-reveal-overlay][data-phase="reveal"] [data-split-reveal-shutter="top"] {
    animation: split-reveal-shutter-top var(--split-reveal-duration) cubic-bezier(0.76, 0, 0.24, 1)
      forwards;
  }
 
  [data-split-reveal-overlay][data-phase="reveal"] [data-split-reveal-shutter="bottom"] {
    animation: split-reveal-shutter-bottom var(--split-reveal-duration)
      cubic-bezier(0.76, 0, 0.24, 1) forwards;
  }
 
  @media (prefers-reduced-motion: reduce) {
    [data-split-reveal-overlay][data-phase="reveal"] [data-split-reveal-shutter] {
      animation: none;
      opacity: 0;
    }
 
    [data-split-reveal-overlay] [data-split-reveal-progress] {
      transition: none;
    }
  }
}
export * from "./split-reveal/index";
export { default } from "./split-reveal/index";

Usage

SplitReveal is bare bones: phase timing, scroll lock, and shutter animation. Loading work and overlay UI are opt-in via composition.

Place it next to your page. Compose a loader task, the overlay, and whichever UI pieces you need.

<section>{/* your layout */}</section>
 
<SplitReveal lockScroll onComplete={() => setReady(true)}>
  <SplitReveal.Images urls={imageUrls} />
  <SplitReveal.Overlay>
    <SplitReveal.Shutter side="top" />
    <SplitReveal.Shutter side="bottom" />
    <SplitReveal.Progress />
  </SplitReveal.Overlay>
</SplitReveal>

Custom async work

Use SplitReveal.Task for any async boot sequence — fetch calls, WASM init, or an async generator that yields progress.

<SplitReveal onComplete={() => setReady(true)}>
  <SplitReveal.Task
    run={async ({ report, signal }) => {
      report({ loaded: 0, total: 3 });
      await warmCache(signal);
      report({ loaded: 1, total: 3 });
      await loadFonts(signal);
      report({ loaded: 2, total: 3 });
      await hydrateStore(signal);
      report({ loaded: 3, total: 3 });
    }}
  />
  <SplitReveal.Overlay>{/* shutters only, or nothing */}</SplitReveal.Overlay>
</SplitReveal>

Async generators work too — yield { loaded, total } updates from SplitReveal.Task:

<SplitReveal.Task
  generator={async function* ({ report, signal }) {
    report({ loaded: 0, total: 2 });
    yield;
    await fetchManifest(signal);
    report({ loaded: 1, total: 2 });
    yield;
    await prefetchRoutes(signal);
    report({ loaded: 2, total: 2 });
  }}
/>

Or drive everything externally with progress and ready — no Task or Images required.

<SplitReveal
  ready={bootProgress >= 100}
  progress={{ loaded: bootProgress, total: 100 }}
  onComplete={() => setReady(true)}
>
  <SplitReveal.Overlay>
    <SplitReveal.Shutter side="top" />
    <SplitReveal.Shutter side="bottom" />
  </SplitReveal.Overlay>
</SplitReveal>

Custom center UI: pass a render function to SplitReveal.Progress or compose ProgressTrack / ProgressCount / ProgressSlot yourself inside Overlay.

Pick only what you need

Source lives in co-located modules under split-reveal/. Import the compound API from the entry file, or pull individual pieces for smaller bundles:

import SplitReveal from "@/animata/preloader/split-reveal";
 
import { SplitRevealRoot } from "@/animata/preloader/split-reveal/root";
import { SplitRevealOverlay } from "@/animata/preloader/split-reveal/overlay";
import { preloadImages } from "@/animata/preloader/split-reveal/preload-images";

Skip Images if you use Task or external ready / progress. Skip Progress* if you only want shutters. The shadcn registry installs every module the entry re-exports — copy individual files manually if you want a minimal subset.

After loading completes, the loader waits (holdMs), fades the progress UI (progressFadeMs), then splits the shutters. Timing runs on CSS keyframes with a custom cubic-bezier. revealDuration defaults to 0.85s.

How it works

SplitReveal.Images preloads URLs with Image() plus decode() when available. SplitReveal.Task accepts a run promise or generator async iterator that reports { loaded, total }.

Scroll locks while the overlay is active and releases on done.

Phases go loadingfade-uirevealdone. Shutters are plain divs animated with @keyframes; no Motion dependency. useSplitReveal() exposes phase, counts, and colors for custom overlay markup.

Restoring a tab from bfcache gets a fresh boot id so the preloader does not stick on a stale 0/0 count.

Changelog

  • 2026-06 — Moved image preloading to opt-in SplitReveal.Images. Added SplitReveal.Task for custom async/generator progress and ready / progress props for external control. Shutters and progress UI are composable under SplitReveal.Overlay. Split source into co-located modules under split-reveal/ for tree-shaking and selective copy.

Credits

Built by hari

Images from Lummi