Typewriter Loop
A looping typewriter CTA with a gradient support, premium touch.
Design
Limitless
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143"use client"; import { useEffect, useState, useCallback } from "react"; import { motion, AnimatePresence, Transition } from "motion/react"; import { cn } from "@/lib/utils"; interface TypewriterLoopProps { LeadText?: string; morphingText?: string[]; className?: string; interval?: number; transition?: Transition; LeadTextClassName?: string; morphingTextClassName?: string; backgroundClassName?: string; cursorClassName?: string; } const TypewriterLoop = ({ LeadText = "Design", morphingText = ["Limitless", "Timeless", "Flawless"], className, interval = 4000, transition = { duration: 0.8, ease: "easeInOut" }, LeadTextClassName, morphingTextClassName, backgroundClassName, cursorClassName, }: TypewriterLoopProps) => { const [index, setIndex] = useState(0); const [colorIndex, setColorIndex] = useState(0); const [nextIndex, setNextIndex] = useState(1); const gradientColors = [ "from-violet-400 to-violet-800 dark:from-violet-400 dark:to-violet-600", "from-blue-400 to-blue-800 dark:from-blue-400 dark:to-blue-600", "from-emerald-400 to-emerald-800 dark:from-emerald-400 dark:to-emerald-600", "from-amber-400 to-amber-800 dark:from-amber-400 dark:to-amber-600", "from-rose-400 to-rose-800 dark:from-rose-400 dark:to-rose-600", "from-fuchsia-400 to-fuchsia-800 dark:from-fuchsia-400 dark:to-fuchsia-600", ]; const backgroundColors = [ "from-transparent via-purple-200/30 to-purple-200 dark:from-transparent dark:via-violet-950/30 dark:to-violet-950/60", "from-transparent via-blue-200/30 to-blue-200 dark:from-transparent dark:via-blue-950/30 dark:to-blue-950/60", "from-transparent via-emerald-200/30 to-emerald-200 dark:from-transparent dark:via-emerald-950/30 dark:to-emerald-950/60", "from-transparent via-amber-200/30 to-amber-200 dark:from-transparent dark:via-amber-950/30 dark:to-amber-950/60", "from-transparent via-rose-200/30 to-rose-200 dark:from-transparent dark:via-rose-950/30 dark:to-rose-950/60", "from-transparent via-fuchsia-200/30 to-fuchsia-200 dark:from-transparent dark:via-fuchsia-950/30 dark:to-fuchsia-950/60", ]; const cursorColors = [ "bg-violet-500", "bg-blue-500", "bg-emerald-500", "bg-amber-500", "bg-rose-500", "bg-fuchsia-500", ]; // Pre-calculate next index useEffect(() => { setNextIndex((index + 1) % morphingText.length); }, [index, morphingText.length]); useEffect(() => { const timer = setInterval(() => { setIndex((prev) => (prev + 1) % morphingText.length); }, interval); return () => clearInterval(timer); }, [morphingText.length, interval]); const handleExitComplete = useCallback(() => { setColorIndex((prev) => (prev + 1) % gradientColors.length); }, [gradientColors.length]); return ( <div className={cn( "flex flex-wrap items-center justify-start w-fit gap-x-2 md:gap-x-3 gap-y-1 text-3xl md:text-7xl font-medium tracking-tight", className, )} > <span className={cn("whitespace-nowrap text-foreground", LeadTextClassName)} > {LeadText} </span> <div className="relative flex items-center"> <AnimatePresence mode="wait" onExitComplete={handleExitComplete}> <motion.div key={morphingText[index]} initial={{ width: 0, opacity: 0 }} animate={{ width: "auto", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={transition} className="overflow-hidden whitespace-nowrap relative" > {/* Background gradient box */} <div className={cn( "absolute inset-0", "bg-gradient-to-r", backgroundColors[colorIndex], backgroundClassName, )} /> <span className={cn( "relative bg-clip-text text-transparent", "bg-gradient-to-r", gradientColors[colorIndex], "pr-1", morphingTextClassName, )} > {morphingText[index]} </span> </motion.div> </AnimatePresence> {/* Cursor Line */} <motion.div key={`cursor-${colorIndex}`} className={cn( "w-[3px] md:w-[4px] h-[1.10em] sm:h-[1em]", cursorColors[colorIndex], cursorClassName, )} animate={{ opacity: [1, 0.5] }} transition={{ duration: 0.8, repeat: Infinity, repeatType: "reverse", }} /> </div> </div> ); }; export default TypewriterLoop;
Installation
pnpm dlx shadcn@latest add @satoriui/typewriter-loop