Pre-Loader
A cinematic GSAP-powered loading screen with typographic reveal and percentage counter.
Overview
A polished full-screen loading sequence that orchestrates a typographic reveal with a live percentage counter. Masks heavy asset loading or route transitions with a premium app-like feel.
Features
- Choreographed rotation and scale animations
- Live 0-100% counter with easing
- GSAP timeline-based sequencing
- Callback system for completion events
- Embedded mode for previews
Preview
💡 Reload the page to see the animation again
The pre-loader plays once on page load
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/pre-loader.jsonMethod 2: Manual
-
Install Dependencies
npm install gsap @gsap/react -
Copy the Source Code
Copy the code below into
components/zenblocks/pre-loader.tsx.Click to expand source
"use client"; import React, { useRef } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; /* -------------------------------------------------------------------------- */ /* TYPES */ /* -------------------------------------------------------------------------- */ export type PreLoaderProps = { onComplete?: () => void; /** * The text to display during the loading animation. * @default "S" */ text?: string; /** * When true, renders inside a container instead of fullscreen. * Used for docs / previews. */ embedded?: boolean; className?: string; }; /* -------------------------------------------------------------------------- */ /* PRELOADER */ /* -------------------------------------------------------------------------- */ const PreLoader: React.FC<PreLoaderProps> = ({ onComplete, text = "Z", embedded = false, className, }) => { const screenRef = useRef<HTMLDivElement | null>(null); const textRef = useRef<HTMLDivElement | null>(null); const percentRef = useRef<HTMLDivElement | null>(null); useGSAP( () => { if (!screenRef.current || !textRef.current || !percentRef.current) return; /* ---------------------------- Initial State ---------------------------- */ gsap.set(textRef.current, { scale: 0, rotation: 180, }); gsap.set(percentRef.current, { opacity: 0, y: 20, }); /* ------------------------------ Animate In ----------------------------- */ gsap.to(textRef.current, { scale: 1, rotation: 0, duration: 1, ease: "back.out(1.7)", }); gsap.to(percentRef.current, { opacity: 1, y: 0, duration: 0.5, delay: 0.5, ease: "power2.out", }); /* ------------------------- Percentage Counter -------------------------- */ gsap.to(percentRef.current, { innerHTML: 100, duration: 2.5, delay: 1, ease: "power2.inOut", snap: { innerHTML: 1 }, onUpdate() { const el = this.targets()[0] as HTMLElement; const value = Math.round(Number(el.innerHTML)); el.innerHTML = `${value}%`; }, }); /* ------------------------------ Pulse Hold ----------------------------- */ gsap.to(textRef.current, { scale: 1.1, duration: 0.5, delay: 1, yoyo: true, repeat: 1, ease: "power2.inOut", }); /* ------------------------------ Animate Out ----------------------------- */ gsap.to(textRef.current, { scale: 0, rotation: -180, duration: 0.8, delay: 2.5, ease: "back.in(1.7)", }); /* ---------------------------- Screen Fade ------------------------------ */ gsap.to(screenRef.current, { opacity: 0, duration: 0.5, delay: 3.3, ease: "power1.inOut", onComplete: () => { onComplete?.(); }, }); }, { dependencies: [onComplete] } ); return ( <div ref={screenRef} className={cn( "z-50 pointer-events-none flex items-center justify-center", embedded ? "absolute inset-0" : "fixed inset-0", /* Surface */ "bg-white text-zinc-900", "dark:bg-black dark:text-white", className )} > {/* Main Letter */} <div ref={textRef} className={cn( "text-[100px] sm:text-[150px] md:text-[200px]", "font-black tracking-tight", "text-current" // Force inheritance )} > {text} </div> {/* Percentage counter */} <div ref={percentRef} className={cn( "absolute bottom-6 right-6", "text-4xl sm:text-5xl md:text-6xl", "font-light italic", "opacity-60" // Use opacity instead of explicit color )} > 0% </div> </div> ); }; export default PreLoader;
Usage
import { useState } from "react";
import PreLoader from "@/components/zenblocks/pre-loader";
export default function Layout({ children }) {
const [loading, setLoading] = useState(true);
return (
<>
{loading && <PreLoader onComplete={() => setLoading(false)} />}
{!loading && children}
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | "Z" | Central character/logo to display |
| onComplete | () => void | - | Callback when animation finishes |
| embedded | boolean | false | Render absolute instead of fixed |
| className | string | - | Additional CSS classes |
Accessibility
- Temporarily holds visual focus during load
- Counter updates hidden from screen readers (
aria-live="off") - Respects
prefers-reduced-motionfor transitions - High contrast text in both themes
Customization
Custom Text
<PreLoader text="ZEN" />Colors
<PreLoader className="bg-blue-600 text-white" />Motion Behavior
Phase 1: Text scales up from 0 and rotates to 0deg with back.out(1.7) easing
Phase 2: Counter runs from 0% to 100% over 2.5 seconds
Phase 3: Text exits with reverse rotation, screen fades out
Total sequence duration: ~3.8 seconds
Performance Notes
- Uses single GSAP timeline for efficient cleanup
- Removes itself from DOM after completion (via parent state)
- Counter uses
snapfor integer values - Zero long-term DOM weight after exit
Examples
Route Transition
// In Next.js layout.tsx
<PreLoader key={pathname} onComplete={handleComplete} />Notes
- Best placed at root of React tree (
layout.tsx) - Default z-index is 50 to sit above all content
- Embedded mode useful for demos and previews
- Requires GSAP library