Split Reveal
Full-screen preloader that loads images, locks scroll, then opens from the center seam.
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.cssPaste 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 loading → fade-ui → reveal → done. 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. AddedSplitReveal.Taskfor custom async/generator progress andready/progressprops for external control. Shutters and progress UI are composable underSplitReveal.Overlay. Split source into co-located modules undersplit-reveal/for tree-shaking and selective copy.
Credits
Built by hari
Images from Lummi