Skip to content
jjswnth/ui

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

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

PropTypeDefaultDescription
panelsnumber10Panels per row. Two rows render, so twice this many.
durationnumber3Seconds the slowest column takes — the total run time.
fastestnumberduration / 3Seconds the fastest (rightmost) column takes.
delaynumber0Seconds to wait before the panels start moving.
colorstring"#ff2d16"Panel colour.
childrenReactNodeLaid over the panels; fades out as they split.
onComplete() => voidFires when the slowest column finishes.