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
import { useState } from "react";
import { WordsPreloader } from "@/components/ui/words-preloader";
export default function WordsPreloaderDemo({ 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 />
<WordsPreloader key={run} />
</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/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
| Prop | Type | Default | Description |
|---|---|---|---|
| words | string[] | 10 greetings | Shown in order, one per `wordDuration`. |
| wordDuration | number | 0.3 | Seconds each word holds. |
| onComplete | () => void | — | Fires once the panel has finished sliding away. |