Preloader
Stairs Preloader
Equal columns clear the screen upward on a ramp — the rightmost snaps away first, the leftmost takes the full three seconds.
jswnth/ui
Components that feel effortless
The page waiting underneath.
jswnth-ui
import { useState } from "react";
import { StairsPreloader } from "@/components/ui/stairs-preloader";
export default function StairsPreloaderDemo({ 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 />
<StairsPreloader key={run} panels={5} duration={3}>
<span className="font-[family-name:var(--font-display,var(--font-sans))] text-[clamp(1.75rem,7vw,3.5rem)] tracking-tight text-white uppercase">
jswnth-ui
</span>
</StairsPreloader>
</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/stairs-preloader.tsx "use client"; import { motion } from "motion/react"; import { useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; type StairsPreloaderProps = { /** Number of equal-width panels covering the container. */ panels?: number; /** Seconds the slowest panel takes — the total run time. */ duration?: number; /** Seconds the fastest panel takes. Defaults to a third of `duration`. */ fastest?: number; /** Seconds to wait before the panels start moving. */ delay?: number; /** Panel colour. */ color?: string; /** Laid over the panels and leaves with the slowest one. */ children?: ReactNode; onComplete?: () => void; className?: string; }; const ease = [0.76, 0, 0.24, 1] as const; /** * Ramps panel durations left to right: index 0 takes the full `duration`, * the rightmost takes `fastest`, everything between is linear. */ export function stairDurations(panels: number, duration: number, fastest: number) { return Array.from({ length: panels }, (_, index) => { const ratio = panels === 1 ? 0 : index / (panels - 1); return duration - ratio * (duration - fastest); }); } export function StairsPreloader({ panels = 5, duration = 3, fastest, delay = 0, color = "#ff2d16", children, onComplete, className, }: StairsPreloaderProps) { const [gone, setGone] = useState(false); const durations = stairDurations(panels, duration, fastest ?? duration / 3); if (gone) return null; return ( <div role="status" aria-label="Loading" className={cn("absolute inset-0 z-50 flex overflow-hidden", className)} > {durations.map((panelDuration, index) => ( <motion.div key={index} initial={{ y: 0 }} animate={{ y: "-100%" }} transition={{ duration: panelDuration, delay, ease }} // Panel 0 is the slowest, so it owns the end of the sequence. onAnimationComplete={ index === 0 ? () => { setGone(true); onComplete?.(); } : undefined } className="h-full flex-1" style={{ backgroundColor: color }} /> ))} {children ? ( <motion.div initial={{ y: 0 }} animate={{ y: "-100%" }} transition={{ duration: durations[0], delay, ease }} className="absolute inset-0 grid place-items-center" > {children} </motion.div> ) : null} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| panels | number | 5 | Number of equal-width panels covering the container. |
| duration | number | 3 | Seconds the slowest panel takes — the total run time. |
| fastest | number | duration / 3 | Seconds the fastest (rightmost) panel takes. |
| delay | number | 0 | Seconds to wait before the panels start moving. |
| color | string | "#ff2d16" | Panel colour. |
| children | ReactNode | — | Laid over the panels; leaves with the slowest one. |
| onComplete | () => void | — | Fires when the slowest panel finishes. |