Skip to content
jjswnth/ui

Preloader

Nike Preloader

Ten frames cut past at a fifth of a second each under bright overlaid type, then the whole panel lifts to reveal the page.

jswnth/ui

Components that feel effortless

The page waiting underneath.

jswnth-ui
01 / 10

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/nike-preloader.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useEffect, useState } from "react";
    import { cn } from "@/lib/utils";
    
    type NikePreloaderProps = {
      /** Cut through these in order, one per `frameDuration`. */
      images: string[];
      /** The bright text laid over the frames. */
      text?: string;
      /** Seconds each frame holds. */
      frameDuration?: number;
      /** Colour of the overlaid text. */
      accent?: string;
      /** Fires once the panel has finished sliding away. */
      onComplete?: () => void;
      className?: string;
    };
    
    const exitEase = [0.76, 0, 0.24, 1] as const;
    
    export function NikePreloader({
      images,
      text = "LOADING",
      frameDuration = 0.2,
      accent = "#ff2d16",
      onComplete,
      className,
    }: NikePreloaderProps) {
      const [index, setIndex] = useState(0);
      const [leaving, setLeaving] = useState(false);
      const [gone, setGone] = useState(false);
    
      useEffect(() => {
        if (leaving) return;
    
        const last = index >= images.length - 1;
        const id = window.setTimeout(
          () => (last ? setLeaving(true) : setIndex((value) => value + 1)),
          frameDuration * 1000,
        );
    
        return () => window.clearTimeout(id);
      }, [frameDuration, images.length, index, leaving]);
    
      if (gone) return null;
    
      return (
        <AnimatePresence
          onExitComplete={() => {
            setGone(true);
            onComplete?.();
          }}
        >
          {!leaving && (
            <motion.div
              role="status"
              aria-label="Loading"
              exit={{ y: "-100%" }}
              transition={{ duration: 0.9, ease: exitEase }}
              className={cn(
                "absolute inset-0 z-50 overflow-hidden bg-black text-white",
                className,
              )}
            >
              {/* All frames stay mounted so switching is a hard cut, never a fetch. */}
              {images.map((image, position) => (
                <img
                  key={image}
                  src={image}
                  alt=""
                  aria-hidden
                  draggable={false}
                  className="absolute inset-0 size-full object-cover"
                  style={{ opacity: position === index ? 1 : 0 }}
                />
              ))}
    
              <div className="absolute inset-0 bg-black/25" />
    
              <div className="absolute inset-0 grid place-items-center px-6">
                <span
                  className="font-[family-name:var(--font-display,var(--font-sans))] text-[clamp(2rem,11vw,7rem)] leading-none tracking-[-0.01em] uppercase"
                  style={{ color: accent }}
                >
                  {text}
                </span>
              </div>
    
              <span
                className="absolute right-5 bottom-4 font-mono text-xs tabular-nums"
                style={{ color: accent }}
              >
                {String(index + 1).padStart(2, "0")} / {String(images.length).padStart(2, "0")}
              </span>
            </motion.div>
          )}
        </AnimatePresence>
      );
    }

Props

PropTypeDefaultDescription
imagesstring[]Frames to cut through, in order.
textstring"LOADING"The bright text laid over the frames.
frameDurationnumber0.2Seconds each frame holds.
accentstring"#ff2d16"Colour of the overlaid text and counter.
onComplete() => voidFires once the panel has finished sliding away.