Skip to content
jjswnth/ui

Preloader

Words Preloader

Hello in ten languages at three tenths of a second each, then the panel lifts away.

jswnth/ui

Components that feel effortless

The page waiting underneath.

Hello

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/words-preloader.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useEffect, useState } from "react";
    import { cn } from "@/lib/utils";
    
    type WordsPreloaderProps = {
      /** Shown in order, one per `wordDuration`. */
      words?: string[];
      /** Seconds each word holds. */
      wordDuration?: number;
      /** Fires once the panel has finished sliding away. */
      onComplete?: () => void;
      className?: string;
    };
    
    const defaultWords = [
      "Hello",
      "Bonjour",
      "Ciao",
      "Olá",
      "やあ",
      "Hallo",
      "Guten tag",
      "Hola",
      "नमस्ते",
      "안녕하세요",
    ];
    
    const exitEase = [0.76, 0, 0.24, 1] as const;
    
    export function WordsPreloader({
      words = defaultWords,
      wordDuration = 0.3,
      onComplete,
      className,
    }: WordsPreloaderProps) {
      const [index, setIndex] = useState(0);
      const [leaving, setLeaving] = useState(false);
      const [gone, setGone] = useState(false);
    
      useEffect(() => {
        if (leaving) return;
    
        const last = index >= words.length - 1;
        const id = window.setTimeout(
          () => (last ? setLeaving(true) : setIndex((value) => value + 1)),
          wordDuration * 1000,
        );
    
        return () => window.clearTimeout(id);
      }, [index, leaving, wordDuration, words.length]);
    
      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 grid place-items-center overflow-hidden bg-[#0b0b0d] text-white",
                className,
              )}
            >
              <div className="flex items-center gap-3">
                <span className="size-2 shrink-0 rounded-full bg-white" />
                {/* Swap is faster than the hold so each word settles before the next. */}
                <AnimatePresence mode="wait">
                  <motion.span
                    key={words[index]}
                    initial={{ opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: -10 }}
                    transition={{ duration: 0.12, ease: "easeOut" }}
                    className="text-[clamp(1.5rem,5vw,2.75rem)] leading-none font-medium tracking-tight"
                  >
                    {words[index]}
                  </motion.span>
                </AnimatePresence>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      );
    }

Props

PropTypeDefaultDescription
wordsstring[]10 greetingsShown in order, one per `wordDuration`.
wordDurationnumber0.3Seconds each word holds.
onComplete() => voidFires once the panel has finished sliding away.