Skip to content
jjswnth/ui

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%

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
boxesnumber10Boxes in the track. Each is worth 100 / boxes percent.
durationnumber4Seconds to travel from 0 to 100.
titlestring"LOADER"Header text on the loader card.
cardsSpawnCard[]3 cardsCards that appear as progress passes each `at` value.
onComplete() => voidFires once the loader has faded out.