Preloader
Double Stairs Preloader
Twenty panels in two rows split apart on the same ramp — the top half exits up, the bottom half down.
jswnth/ui
Components that feel effortless
The page waiting underneath.
jswnth-ui
import { useState } from "react";
import { DoubleStairsPreloader } from "@/components/ui/double-stairs-preloader";
export default function DoubleStairsPreloaderDemo({ 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 />
<DoubleStairsPreloader key={run} panels={10} 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>
</DoubleStairsPreloader>
</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/double-stairs-preloader.tsx "use client"; import { motion } from "motion/react"; import { useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; type DoubleStairsPreloaderProps = { /** Panels per row. The component renders two rows, so twice this many. */ panels?: number; /** Seconds the slowest column takes — the total run time. */ duration?: number; /** Seconds the fastest column 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 fades out with the slowest column. */ children?: ReactNode; onComplete?: () => void; className?: string; }; const ease = [0.76, 0, 0.24, 1] as const; 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 DoubleStairsPreloader({ panels = 10, duration = 3, fastest, delay = 0, color = "#ff2d16", children, onComplete, className, }: DoubleStairsPreloaderProps) { 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 flex-col overflow-hidden", className)} > {/* Top row retreats upward, bottom row downward, on the same ramp. */} {(["-100%", "100%"] as const).map((target, row) => ( <div key={target} className="flex h-1/2 w-full"> {durations.map((panelDuration, index) => ( <motion.div key={index} initial={{ y: 0 }} animate={{ y: target }} transition={{ duration: panelDuration, delay, ease }} // The slowest panel of the top row closes out the sequence. onAnimationComplete={ row === 0 && index === 0 ? () => { setGone(true); onComplete?.(); } : undefined } className="h-full flex-1" style={{ backgroundColor: color }} /> ))} </div> ))} {children ? ( <motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: durations[0]! * 0.5, delay, ease: "easeIn" }} className="absolute inset-0 grid place-items-center" > {children} </motion.div> ) : null} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| panels | number | 10 | Panels per row. Two rows render, so twice this many. |
| duration | number | 3 | Seconds the slowest column takes — the total run time. |
| fastest | number | duration / 3 | Seconds the fastest (rightmost) column takes. |
| delay | number | 0 | Seconds to wait before the panels start moving. |
| color | string | "#ff2d16" | Panel colour. |
| children | ReactNode | — | Laid over the panels; fades out as they split. |
| onComplete | () => void | — | Fires when the slowest column finishes. |