ScrollX UI
uianimated-textgeneratesource: ok · hit
Animated TextGenerate
Generates animated text word-by-word with blur, highlights, links, and smooth effects.
npx shadcn add "https://www.scrollxui.dev/registry/animated-textgenerate.json"free
Visual style
glass-blur
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files2 · source cached 10/9/2026, 11:06:15 PM
components/ui/animated-textgenerate.tsx
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
interface AnimatedTextGenerateProps {
text: string;
className?: string;
textClassName?: string;
blurEffect?: boolean;
speed?: number;
highlightWords?: string[];
highlightClassName?: string;
linkWords?: string[];
linkHrefs?: string[];
linkClassNames?: string[];
}
export const AnimatedTextGenerate = ({
text,
className,
textClassName,
blurEffect = true,
speed = 0.5,
highlightWords = [],
highlightClassName,
linkWords = [],
linkHrefs = [],
linkClassNames = [],
}: AnimatedTextGenerateProps) => {
const [currentText, setCurrentText] = useState(text);
const [visibleCount, setVisibleCount] = useState(0);
const splitWords = text.split(' ');
if (currentText !== text) {
setCurrentText(text);
setVisibleCount(0);
}
useEffect(() => {
const intervalId = setInterval(
() => {
setVisibleCount((prev) => {
if (prev >= splitWords.length) {
clearInterval(intervalId);
return prev;
}
return prev + 1;
});
},
Math.max(speed * 200, 100),
);
return () => clearInterval(intervalId);
}, [speed, splitWords.length]);
const generateWords = () => {
return (
<div className='flex flex-wrap items-center gap-1'>
{splitWords.map((word, idx) => {
const isVisible = idx < visibleCount;
const remaining = splitWords.length - visibleCount;
let capsuleCount = 4;
if (remaining <= 2) capsuleCount = remaining;
else if (remaining <= 4) capsuleCount = Math.min(3, remaining);
else if (visibleCount === 0) capsuleCount = 2;
else if (visibleCount < 3) capsuleCount = 3;
const isUpcoming =
idx >= visibleCount && idx < visibleCount + capsuleCount;
const isHighlight =
highlightWords.length > 0 &&
highlightWords.some((hw) =>
word.toLowerCase().includes(hw.toLowerCase()),
);
const linkIndex = linkWords.findIndex((lw) =>
word.toLowerCase().includes(lw.toLowerCase()),
);
const isLink = linkIndex !== -1;
if (isVisible) {
const wordElement = (
<motion.span
key={`${word}-${idx}`}
initial={{
opacity: 0,
filter: blurEffect ? 'blur(10px)' : 'none',
}}
animate={{
opacity: 1,
filter: blurEffect ? 'blur(0px)' : 'none',
}}
transition={{
duration: speed * 0.3,
ease: 'easeOut',
}}
className={cn(
'dark:text-white text-black',
isHighlight && highlightClassName,
)}
>
{word}
</motion.span>
);
if (isLink && linkHrefs[linkIndex]) {
return (
<Link
href={linkHrefs[linkIndex]}
key={`link-${idx}`}
className={cn(linkClassNames[linkIndex])}
>
{wordElement}
</Link>
);
}
return wordElement;
}
if (isUpcoming) {
return (
<motion.div
key={`placeholder-${idx}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.4, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className='bg-black dark:bg-gray-600 rounded-full'
style={{
width: `${Math.max(word.length * 0.7, 2.5)}em`,
height: '0.9em',
display: 'inline-block',
}}
/>
);
}
return null;
})}
</div>
);
};
return (
<div className={cn('font-bold', className)}>
<div className='mt-4'>
<div
className={cn(
'dark:text-white text-black text-2xl leading-snug tracking-wide',
textClassName,
)}
>
{generateWords()}
</div>
</div>
</div>
);
};
lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}