Back to library
text
Text Reveal
Reveals text by word, character, or line with fade, slide, scale, and blur presets. Motion-powered.
Inspired by Cnippet UI · View source
Preview
Reveal text word by word
Character stagger with a slide preset
Copy for AI
Copy for Lovable is the fastest way to move this component into another Lovable project — it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.
Installation
npx shadcn@latest add @cnippet/text-revealPaste into a terminal — or paste into a Lovable chat and I'll run it for you.
Source files
components/ui/text-reveal.tsx
"use client";
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react";
import type React from "react";
import { cn } from "@/lib/utils";
export type TextRevealPreset =
| "blur"
| "fade-in-blur"
| "scale"
| "fade"
| "slide";
export type TextRevealPer = "word" | "char" | "line";
export type TextRevealProps = {
children: string;
per?: TextRevealPer;
as?: keyof React.JSX.IntrinsicElements;
variants?: { container?: Variants; item?: Variants };
className?: string;
preset?: TextRevealPreset;
delay?: number;
speedReveal?: number;
speedSegment?: number;
trigger?: boolean;
onAnimationComplete?: () => void;
onAnimationStart?: () => void;
segmentWrapperClassName?: string;
containerTransition?: Transition;
segmentTransition?: Transition;
style?: React.CSSProperties;
};
const defaultStaggerTimes: Record<TextRevealPer, number> = {
char: 0.03,
line: 0.1,
word: 0.05,
};
const defaultContainerVariants: Variants = {
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05 },
},
};
const defaultItemVariants: Variants = {
exit: { opacity: 0 },
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const presetVariants: Record<
TextRevealPreset,
{ container: Variants; item: Variants }
> = {
blur: {
container: defaultContainerVariants,
item: {
exit: { filter: "blur(12px)", opacity: 0 },
hidden: { filter: "blur(12px)", opacity: 0 },
visible: { filter: "blur(0px)", opacity: 1 },
},
},
fade: {
container: defaultContainerVariants,
item: {
exit: { opacity: 0 },
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
},
"fade-in-blur": {
container: defaultContainerVariants,
item: {
exit: { filter: "blur(12px)", opacity: 0, y: 20 },
hidden: { filter: "blur(12px)", opacity: 0, y: 20 },
visible: { filter: "blur(0px)", opacity: 1, y: 0 },
},
},
scale: {
container: defaultContainerVariants,
item: {
exit: { opacity: 0, scale: 0 },
hidden: { opacity: 0, scale: 0 },
visible: { opacity: 1, scale: 1 },
},
},
slide: {
container: defaultContainerVariants,
item: {
exit: { opacity: 0, y: 20 },
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
},
},
};
function splitText(text: string, per: TextRevealPer) {
if (per === "line") return text.split("\n");
return text.split(/(\s+)/);
}
function SegmentItem({
segment,
variants,
per,
wrapperClassName,
}: {
segment: string;
variants: Variants;
per: TextRevealPer;
wrapperClassName?: string;
}) {
const content =
per === "line" ? (
<motion.span className="block" variants={variants}>
{segment}
</motion.span>
) : per === "word" ? (
<motion.span
aria-hidden="true"
className="inline-block whitespace-pre"
variants={variants}
>
{segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{segment.split("").map((char, i) => (
<motion.span
aria-hidden="true"
className="inline-block whitespace-pre"
key={i}
variants={variants}
>
{char}
</motion.span>
))}
</motion.span>
);
if (!wrapperClassName) return content;
return (
<span
className={cn(
per === "line" ? "block" : "inline-block",
wrapperClassName,
)}
>
{content}
</span>
);
}
export function TextReveal({
children,
per = "word",
as = "p",
variants,
className,
preset = "fade",
delay = 0,
speedReveal = 1,
speedSegment = 1,
trigger = true,
onAnimationComplete,
onAnimationStart,
segmentWrapperClassName,
containerTransition,
segmentTransition,
style,
}: TextRevealProps) {
const segments = splitText(children, per);
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
const base = preset
? presetVariants[preset]
: { container: defaultContainerVariants, item: defaultItemVariants };
const stagger = defaultStaggerTimes[per] / speedReveal;
const baseDuration = 0.3 / speedSegment;
const containerVars: Variants = {
...base.container,
visible: {
...base.container.visible,
transition: {
delayChildren: delay,
staggerChildren: stagger,
...containerTransition,
},
},
};
const itemVars: Variants = {
...base.item,
visible: {
...(base.item.visible as object),
transition: { duration: baseDuration, ...segmentTransition },
},
};
const computedVariants = variants
? {
container: { ...containerVars, ...variants.container },
item: { ...itemVars, ...variants.item },
}
: { container: containerVars, item: itemVars };
return (
<AnimatePresence mode="popLayout">
{trigger && (
<MotionTag
animate="visible"
className={className}
exit="exit"
initial="hidden"
onAnimationComplete={onAnimationComplete}
onAnimationStart={onAnimationStart}
style={style}
variants={computedVariants.container}
>
{per !== "line" ? <span className="sr-only">{children}</span> : null}
{segments.map((segment, index) => (
<SegmentItem
key={`${per}-${index}-${segment}`}
per={per}
segment={segment}
variants={computedVariants.item}
wrapperClassName={segmentWrapperClassName}
/>
))}
</MotionTag>
)}
</AnimatePresence>
);
}
Dependencies
motion