Preloader
Box Loader
A ten-box progress track that darkens a box per tenth, spawning tilted info cards into the corners as it climbs past 60, 70 and 80 percent.
jswnth/ui
Components that feel effortless
The page waiting underneath.
LOADER
0%
import { useState } from "react";
import { BoxLoader } from "@/components/ui/box-loader";
export default function BoxLoaderDemo({ fullscreen }: { fullscreen?: boolean }) {
const [run, setRun] = useState(0);
return (
<div className={fullscreen ? "w-full space-y-4" : "w-full max-w-xl space-y-4"}>
<div
className="relative w-full overflow-hidden rounded-card border border-line"
style={fullscreen ? { height: "78dvh" } : { aspectRatio: "16 / 10" }}
>
<Hero />
<BoxLoader key={run} duration={4} />
</div>
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="mx-auto block rounded-full border border-line bg-surface px-4 py-1.5 text-xs font-medium text-muted transition-colors hover:text-ink"
>
Replay
</button>
</div>
);
}
function Hero() {
return (
<div className="grid h-full place-items-center bg-elevated px-6 text-center">
<div>
<p className="text-xs tracking-widest text-faint uppercase">jswnth/ui</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
Components that feel effortless
</h3>
<p className="mt-2 text-sm text-muted">The page waiting underneath.</p>
</div>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/box-loader.tsx "use client"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type SpawnCard = { /** Progress percentage at which this card appears. */ at: number; position: "top-left" | "top-right" | "bottom-left" | "bottom-right"; /** Degrees of tilt. */ rotate?: number; /** Renders the same header as the loader card when set. */ title?: string; content: ReactNode; }; type BoxLoaderProps = { /** Boxes in the track. Each one is worth `100 / boxes` percent. */ boxes?: number; /** Seconds to travel from 0 to 100. */ duration?: number; /** Header text on the loader card. */ title?: string; /** Cards that appear as the progress passes their `at` value. */ cards?: SpawnCard[]; /** Fires once the loader has faded out. */ onComplete?: () => void; className?: string; }; const corners: Record<SpawnCard["position"], string> = { "top-left": "top-4 left-4 origin-top-left", "top-right": "top-4 right-4 origin-top-right", "bottom-left": "bottom-4 left-4 origin-bottom-left", "bottom-right": "bottom-4 right-4 origin-bottom-right", }; const defaultCards: SpawnCard[] = [ { at: 60, position: "bottom-right", title: "USEFUL INFO", content: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Incidunt perferendis eum pariatur dolore, quod ipsum expedita ducimus.", }, { at: 70, position: "top-right", rotate: 9, content: "WITH ♥ FROM\nPUNJAB, INDIA", }, { at: 80, position: "bottom-left", rotate: -9, content: "LOADERS, SHADERS,\nGREAT UI/UX\n\nCOLLECTION\nSEP 2025", }, ]; export function BoxLoader({ boxes = 10, duration = 4, title = "LOADER", cards = defaultCards, onComplete, className, }: BoxLoaderProps) { const [progress, setProgress] = useState(0); const [gone, setGone] = useState(false); useEffect(() => { if (progress >= 100) return; const id = window.setTimeout( () => setProgress((value) => Math.min(100, value + 1)), (duration * 1000) / 100, ); return () => window.clearTimeout(id); }, [duration, progress]); const finished = progress >= 100; if (gone) return null; return ( <AnimatePresence onExitComplete={() => { setGone(true); onComplete?.(); }} > {!finished && ( <motion.div role="status" aria-label={`Loading, ${progress} percent`} exit={{ opacity: 0 }} transition={{ duration: 0.5, delay: 0.4, ease: "easeInOut" }} className={cn( "absolute inset-0 z-50 grid place-items-center overflow-hidden bg-[#f0f0f0] text-[#4a4a4a]", className, )} > {/* Loader card ------------------------------------------------- */} <div className="w-[min(24rem,80%)] rounded-2xl bg-white p-4 shadow-[0_10px_30px_-18px_rgb(0_0_0/0.35)]"> <CardHeader title={title} /> <div className="mt-4 flex items-center gap-1.5 rounded-xl bg-[#f2f2f2] p-2"> {Array.from({ length: boxes }, (_, index) => { // A box darkens once the progress has fully covered its slice; // the mid-grey you catch in between is the colour transition. const filled = progress >= ((index + 1) * 100) / boxes; return ( <motion.span key={index} className="aspect-square flex-1 rounded-[5px]" animate={{ backgroundColor: filled ? "#4a4a4a" : "#e2e2e2", scale: filled ? 1 : 0.86, }} transition={{ duration: 0.35, ease: "easeOut" }} /> ); })} </div> <p className="mt-3 text-right text-sm tabular-nums">{progress}%</p> </div> {/* Cards that spawn on the way up ------------------------------ */} <AnimatePresence> {cards .filter((card) => progress >= card.at) .map((card) => ( <motion.div key={card.at} initial={{ opacity: 0, scale: 0.8, rotate: 0 }} animate={{ opacity: 1, scale: 1, rotate: card.rotate ?? 0 }} transition={{ type: "spring", stiffness: 240, damping: 22 }} className={cn( "absolute w-[min(14rem,38%)] rounded-2xl bg-white p-3 shadow-[0_10px_30px_-18px_rgb(0_0_0/0.35)]", corners[card.position], )} > {card.title ? <CardHeader title={card.title} /> : null} <p className={cn( "text-[11px] leading-relaxed whitespace-pre-line text-[#9a9a9a]", card.title ? "mt-2" : "font-mono tracking-wide uppercase", )} > {card.content} </p> </motion.div> ))} </AnimatePresence> </motion.div> )} </AnimatePresence> ); } function CardHeader({ title }: { title: string }) { return ( <div className="flex items-center justify-between"> <span className="text-[11px] tracking-[0.12em] text-[#7a7a7a] uppercase"> {title} </span> <span className="flex gap-1"> <span className="size-1.5 rounded-full bg-[#e2e2e2]" /> <span className="size-1.5 rounded-full bg-[#e2e2e2]" /> </span> </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| boxes | number | 10 | Boxes in the track. Each is worth 100 / boxes percent. |
| duration | number | 4 | Seconds to travel from 0 to 100. |
| title | string | "LOADER" | Header text on the loader card. |
| cards | SpawnCard[] | 3 cards | Cards that appear as progress passes each `at` value. |
| onComplete | () => void | — | Fires once the loader has faded out. |