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 / 10import { useState } from "react";
import { NikePreloader } from "@/components/ui/nike-preloader";
const images = Array.from(
{ length: 10 },
(_, index) => `https://picsum.photos/seed/nike-${index}/900/560`,
);
export default function NikePreloaderDemo({ 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"}>
{/* The preloader is absolute, so the stage just needs to be relative. */}
<div
className="relative w-full overflow-hidden rounded-card border border-line"
style={fullscreen ? { height: "78dvh" } : { aspectRatio: "16 / 10" }}
>
<Hero />
<NikePreloader key={run} images={images} text="jswnth-ui" />
</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/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
| Prop | Type | Default | Description |
|---|---|---|---|
| images | string[] | — | Frames to cut through, in order. |
| text | string | "LOADING" | The bright text laid over the frames. |
| frameDuration | number | 0.2 | Seconds each frame holds. |
| accent | string | "#ff2d16" | Colour of the overlaid text and counter. |
| onComplete | () => void | — | Fires once the panel has finished sliding away. |