Skip to content
jjswnth/ui

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

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/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

PropTypeDefaultDescription
panelsnumber5Number of equal-width panels covering the container.
durationnumber3Seconds the slowest panel takes — the total run time.
fastestnumberduration / 3Seconds the fastest (rightmost) panel takes.
delaynumber0Seconds to wait before the panels start moving.
colorstring"#ff2d16"Panel colour.
childrenReactNodeLaid over the panels; leaves with the slowest one.
onComplete() => voidFires when the slowest panel finishes.