A powerful text animation component featuring SVG vector outline path drawing, spring character physics, dynamic viewBox scaling, and 12 Google fonts.
Customize animation styles, fonts, speeds, font sizes, colors, and gradient themes.
Calculates dynamic SVG viewBox aspect ratio so long text never cuts off.
Includes Write, Bounce, Wave, Reveal, Typewriter, Fade, Scale, and Slide transitions.
Includes Calligraphy, Cursive, Script, Serif, Sans, Display, and Cinematic typography styles.
Hardware-accelerated 60fps animations with spring physics and full speed control.
Install framer-motion and lucide-react.
pnpm add framer-motion lucide-reactCreate components/ui/text-writing-effect.tsx.
"use client"
import * as React from "react"
import { motion, HTMLMotionProps, Variants } from "framer-motion"
import { cn } from "@/lib/utils"
export type AnimationStyle =
| "write"
| "reveal"
| "bounce"
| "wave"
| "typewriter"
| "fade"
| "scale"
| "slide"
export interface TextWritingEffectProps
extends Omit<HTMLMotionProps<"div">, "children"> {
/** The text string to animate */
text: string
/** Animation style to apply */
animationStyle?: AnimationStyle
/** Font class layout from Next.js google fonts or standard Tailwind */
fontClassName?: string
/** Total animation duration in seconds */
speed?: number
/** Standard solid color fallback. Defaults to 'currentColor' */
color?: string
/** Font size in pixels (number) or CSS clamp/responsive string */
fontSize?: number | string
/** An array of colors to build a vibrant linear gradient */
gradientColors?: string[]
/** Thickness of the traced vector line (for 'write' style) */
strokeWidth?: number
/** Multiplier determining when solid fill fades in relative to speed (0-1) */
fillDelayRatio?: number
/** Delay before starting animation in seconds */
delay?: number
/** If true, the animation plays when scrolled into view */
triggerOnView?: boolean
/** Custom SVG viewBox override for 'write' mode */
viewBox?: string
}
export const TextWritingEffect = React.forwardRef<
HTMLDivElement,
TextWritingEffectProps
>(
(
{
text,
animationStyle = "write",
fontClassName,
speed = 2,
color = "currentColor",
fontSize = 64,
gradientColors,
strokeWidth = 1.5,
fillDelayRatio = 0.55,
delay = 0,
triggerOnView = false,
viewBox,
className,
...props
},
ref
) => {
const uniqueId = React.useId()
const gradientId = `text-write-gradient-${uniqueId.replace(/:/g, "")}`
const isGradient = Array.isArray(gradientColors) && gradientColors.length > 0
const paintTarget = isGradient ? `url(#${gradientId})` : color
// Numeric base estimation for SVG calculations
const numericFontSize = typeof fontSize === "number" ? fontSize : 64
// Viewport listener options
const interactionProps = triggerOnView
? {
initial: "initial",
whileInView: "animate",
viewport: { once: true, margin: "-10%" },
}
: { initial: "initial", animate: "animate" }
// --- Mode 1: SVG Vector Writing Path Animation ---
if (animationStyle === "write") {
const charCount = text.length || 1
const calculatedWidth = Math.max(
900,
Math.round(charCount * (numericFontSize * 0.85) + numericFontSize * 2)
)
const calculatedHeight = Math.max(240, Math.round(numericFontSize * 3.5))
const svgViewBox = viewBox || `0 0 ${calculatedWidth} ${calculatedHeight}`
const pathLength = Math.max(2400, charCount * numericFontSize * 3)
const strokeVariants: Variants = {
initial: {
strokeDasharray: pathLength,
strokeDashoffset: pathLength,
},
animate: {
strokeDashoffset: 0,
transition: {
duration: Math.max(0.4, speed),
delay,
ease: [0.42, 0, 0.58, 1],
},
},
}
const fillVariants: Variants = {
initial: {
opacity: 0,
scale: 0.985,
},
animate: {
opacity: 1,
scale: 1,
transition: {
duration: 0.6,
ease: "easeOut",
delay: delay + speed * fillDelayRatio,
},
},
}
return (
<motion.div
ref={ref}
className={cn(
"flex h-auto w-full items-center justify-center overflow-visible p-4 select-none",
className
)}
{...interactionProps}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={svgViewBox}
className="h-full w-full max-w-full overflow-visible"
preserveAspectRatio="xMidYMid meet"
>
<defs>
{isGradient && (
<linearGradient
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
{gradientColors.map((stopColor, idx) => {
const calculatedOffset =
(idx / (gradientColors.length - 1)) * 100
return (
<stop
key={`${stopColor}-${idx}`}
offset={`${calculatedOffset}%`}
stopColor={stopColor}
/>
)
})}
</linearGradient>
)}
</defs>
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className={cn("fill-transparent", fontClassName)}
style={{
stroke: paintTarget,
fontSize:
typeof fontSize === "number" ? `${fontSize}px` : fontSize,
}}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
variants={strokeVariants}
>
{text}
</motion.text>
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className={cn(
"pointer-events-none stroke-transparent",
fontClassName
)}
style={{
fill: paintTarget,
fontSize:
typeof fontSize === "number" ? `${fontSize}px` : fontSize,
}}
variants={fillVariants}
>
{text}
</motion.text>
</svg>
</motion.div>
)
}
// --- Mode 2: Character-by-Character Animations (Grouped by Words) ---
const words = text.split(" ")
const totalChars = text.length || 1
const getInitial = () => {
switch (animationStyle) {
case "reveal":
return { opacity: 0, y: 22, filter: "blur(12px)" }
case "bounce":
return { opacity: 0, y: -40, scale: 0.3 }
case "wave":
return { opacity: 0, y: 24, rotateX: -90, scale: 0.8 }
case "typewriter":
return { opacity: 0, scale: 0.75, filter: "blur(4px)" }
case "fade":
return { opacity: 0, y: 14 }
case "scale":
return { opacity: 0, scale: 0.15, rotate: -20 }
case "slide":
return { opacity: 0, x: -50, skewX: 25 }
default:
return { opacity: 0, y: 12 }
}
}
const getAnimate = () => {
switch (animationStyle) {
case "reveal":
return { opacity: 1, y: 0, filter: "blur(0px)" }
case "bounce":
return { opacity: 1, y: 0, scale: 1 }
case "wave":
return { opacity: 1, y: 0, rotateX: 0, scale: 1 }
case "typewriter":
return { opacity: 1, scale: 1, filter: "blur(0px)" }
case "fade":
return { opacity: 1, y: 0 }
case "scale":
return { opacity: 1, scale: 1, rotate: 0 }
case "slide":
return { opacity: 1, x: 0, skewX: 0 }
default:
return { opacity: 1, y: 0 }
}
}
const getTransition = (index: number) => {
const charDelay = delay + (index / totalChars) * (speed * 0.65)
const baseDuration = Math.max(0.2, speed * 0.35)
if (animationStyle === "bounce") {
return {
type: "spring" as const,
stiffness: 350,
damping: 14,
delay: charDelay,
}
}
if (animationStyle === "typewriter") {
return {
duration: 0.1,
delay: charDelay,
ease: "linear" as const,
}
}
return {
duration: baseDuration,
delay: charDelay,
ease: "easeOut" as const,
}
}
const gradientStyle = isGradient
? {
backgroundImage: `linear-gradient(to right, ${gradientColors.join(", ")})`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: { color }
const autoPaddingX = "0.15em"
const autoPaddingY = "0.25em"
const autoMarginX = "-0.07em"
const autoMarginY = "-0.12em"
let globalCharOffset = 0
return (
<motion.div
ref={ref}
className={cn(
"flex flex-wrap items-center justify-center overflow-visible p-2 text-center select-none",
className
)}
{...interactionProps}
{...props}
>
{words.map((word, wordIdx) => {
const wordStartOffset = globalCharOffset
globalCharOffset += word.length + 1
return (
<span
key={`word-${wordIdx}-${word}`}
className="inline-flex flex-nowrap items-center whitespace-nowrap overflow-visible"
>
{word.split("").map((character, charIdx) => {
const charIndex = wordStartOffset + charIdx
return (
<motion.span
key={`${character}-${charIndex}`}
initial={getInitial()}
animate={getAnimate()}
transition={getTransition(charIndex)}
className={cn(
"inline-block overflow-visible whitespace-pre",
fontClassName
)}
style={{
...gradientStyle,
fontSize:
typeof fontSize === "number"
? `${fontSize}px`
: fontSize,
lineHeight: 1.4,
paddingLeft: autoPaddingX,
paddingRight: autoPaddingX,
paddingTop: autoPaddingY,
paddingBottom: autoPaddingY,
marginLeft: autoMarginX,
marginRight: autoMarginX,
marginTop: autoMarginY,
marginBottom: autoMarginY,
}}
>
{character}
</motion.span>
)
})}
{wordIdx < words.length - 1 && (
<span
className="inline-block"
style={{
fontSize:
typeof fontSize === "number"
? `${fontSize}px`
: fontSize,
width: "0.25em",
}}
>
</span>
)}
</span>
)
})}
</motion.div>
)
}
)
TextWritingEffect.displayName = "TextWritingEffect"
Import and add the component to your Next.js pages or components.
import { TextWritingEffect } from "@/components/ui/text-writing-effect"
export default function Page() {
return (
<TextWritingEffect
text="Mutasim Fuad Rimu"
animationStyle="write"
fontSize={68}
speed={2}
gradientColors={["#10b981", "#6366f1"]}
/>
)
}"use client"
import * as React from "react"
import { motion, HTMLMotionProps, Variants } from "framer-motion"
import { cn } from "@/lib/utils"
export type AnimationStyle =
| "write"
| "reveal"
| "bounce"
| "wave"
| "typewriter"
| "fade"
| "scale"
| "slide"
export interface TextWritingEffectProps
extends Omit<HTMLMotionProps<"div">, "children"> {
/** The text string to animate */
text: string
/** Animation style to apply */
animationStyle?: AnimationStyle
/** Font class layout from Next.js google fonts or standard Tailwind */
fontClassName?: string
/** Total animation duration in seconds */
speed?: number
/** Standard solid color fallback. Defaults to 'currentColor' */
color?: string
/** Font size in pixels (number) or CSS clamp/responsive string */
fontSize?: number | string
/** An array of colors to build a vibrant linear gradient */
gradientColors?: string[]
/** Thickness of the traced vector line (for 'write' style) */
strokeWidth?: number
/** Multiplier determining when solid fill fades in relative to speed (0-1) */
fillDelayRatio?: number
/** Delay before starting animation in seconds */
delay?: number
/** If true, the animation plays when scrolled into view */
triggerOnView?: boolean
/** Custom SVG viewBox override for 'write' mode */
viewBox?: string
}
export const TextWritingEffect = React.forwardRef<
HTMLDivElement,
TextWritingEffectProps
>(
(
{
text,
animationStyle = "write",
fontClassName,
speed = 2,
color = "currentColor",
fontSize = 64,
gradientColors,
strokeWidth = 1.5,
fillDelayRatio = 0.55,
delay = 0,
triggerOnView = false,
viewBox,
className,
...props
},
ref
) => {
const uniqueId = React.useId()
const gradientId = `text-write-gradient-${uniqueId.replace(/:/g, "")}`
const isGradient = Array.isArray(gradientColors) && gradientColors.length > 0
const paintTarget = isGradient ? `url(#${gradientId})` : color
// Numeric base estimation for SVG calculations
const numericFontSize = typeof fontSize === "number" ? fontSize : 64
// Viewport listener options
const interactionProps = triggerOnView
? {
initial: "initial",
whileInView: "animate",
viewport: { once: true, margin: "-10%" },
}
: { initial: "initial", animate: "animate" }
// --- Mode 1: SVG Vector Writing Path Animation ---
if (animationStyle === "write") {
const charCount = text.length || 1
const calculatedWidth = Math.max(
900,
Math.round(charCount * (numericFontSize * 0.85) + numericFontSize * 2)
)
const calculatedHeight = Math.max(240, Math.round(numericFontSize * 3.5))
const svgViewBox = viewBox || `0 0 ${calculatedWidth} ${calculatedHeight}`
const pathLength = Math.max(2400, charCount * numericFontSize * 3)
const strokeVariants: Variants = {
initial: {
strokeDasharray: pathLength,
strokeDashoffset: pathLength,
},
animate: {
strokeDashoffset: 0,
transition: {
duration: Math.max(0.4, speed),
delay,
ease: [0.42, 0, 0.58, 1],
},
},
}
const fillVariants: Variants = {
initial: {
opacity: 0,
scale: 0.985,
},
animate: {
opacity: 1,
scale: 1,
transition: {
duration: 0.6,
ease: "easeOut",
delay: delay + speed * fillDelayRatio,
},
},
}
return (
<motion.div
ref={ref}
className={cn(
"flex h-auto w-full items-center justify-center overflow-visible p-4 select-none",
className
)}
{...interactionProps}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={svgViewBox}
className="h-full w-full max-w-full overflow-visible"
preserveAspectRatio="xMidYMid meet"
>
<defs>
{isGradient && (
<linearGradient
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
{gradientColors.map((stopColor, idx) => {
const calculatedOffset =
(idx / (gradientColors.length - 1)) * 100
return (
<stop
key={`${stopColor}-${idx}`}
offset={`${calculatedOffset}%`}
stopColor={stopColor}
/>
)
})}
</linearGradient>
)}
</defs>
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className={cn("fill-transparent", fontClassName)}
style={{
stroke: paintTarget,
fontSize:
typeof fontSize === "number" ? `${fontSize}px` : fontSize,
}}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
variants={strokeVariants}
>
{text}
</motion.text>
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className={cn(
"pointer-events-none stroke-transparent",
fontClassName
)}
style={{
fill: paintTarget,
fontSize:
typeof fontSize === "number" ? `${fontSize}px` : fontSize,
}}
variants={fillVariants}
>
{text}
</motion.text>
</svg>
</motion.div>
)
}
// --- Mode 2: Character-by-Character Animations (Grouped by Words) ---
const words = text.split(" ")
const totalChars = text.length || 1
const getInitial = () => {
switch (animationStyle) {
case "reveal":
return { opacity: 0, y: 22, filter: "blur(12px)" }
case "bounce":
return { opacity: 0, y: -40, scale: 0.3 }
case "wave":
return { opacity: 0, y: 24, rotateX: -90, scale: 0.8 }
case "typewriter":
return { opacity: 0, scale: 0.75, filter: "blur(4px)" }
case "fade":
return { opacity: 0, y: 14 }
case "scale":
return { opacity: 0, scale: 0.15, rotate: -20 }
case "slide":
return { opacity: 0, x: -50, skewX: 25 }
default:
return { opacity: 0, y: 12 }
}
}
const getAnimate = () => {
switch (animationStyle) {
case "reveal":
return { opacity: 1, y: 0, filter: "blur(0px)" }
case "bounce":
return { opacity: 1, y: 0, scale: 1 }
case "wave":
return { opacity: 1, y: 0, rotateX: 0, scale: 1 }
case "typewriter":
return { opacity: 1, scale: 1, filter: "blur(0px)" }
case "fade":
return { opacity: 1, y: 0 }
case "scale":
return { opacity: 1, scale: 1, rotate: 0 }
case "slide":
return { opacity: 1, x: 0, skewX: 0 }
default:
return { opacity: 1, y: 0 }
}
}
const getTransition = (index: number) => {
const charDelay = delay + (index / totalChars) * (speed * 0.65)
const baseDuration = Math.max(0.2, speed * 0.35)
if (animationStyle === "bounce") {
return {
type: "spring" as const,
stiffness: 350,
damping: 14,
delay: charDelay,
}
}
if (animationStyle === "typewriter") {
return {
duration: 0.1,
delay: charDelay,
ease: "linear" as const,
}
}
return {
duration: baseDuration,
delay: charDelay,
ease: "easeOut" as const,
}
}
const gradientStyle = isGradient
? {
backgroundImage: `linear-gradient(to right, ${gradientColors.join(", ")})`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}
: { color }
const autoPaddingX = "0.15em"
const autoPaddingY = "0.25em"
const autoMarginX = "-0.07em"
const autoMarginY = "-0.12em"
let globalCharOffset = 0
return (
<motion.div
ref={ref}
className={cn(
"flex flex-wrap items-center justify-center overflow-visible p-2 text-center select-none",
className
)}
{...interactionProps}
{...props}
>
{words.map((word, wordIdx) => {
const wordStartOffset = globalCharOffset
globalCharOffset += word.length + 1
return (
<span
key={`word-${wordIdx}-${word}`}
className="inline-flex flex-nowrap items-center whitespace-nowrap overflow-visible"
>
{word.split("").map((character, charIdx) => {
const charIndex = wordStartOffset + charIdx
return (
<motion.span
key={`${character}-${charIndex}`}
initial={getInitial()}
animate={getAnimate()}
transition={getTransition(charIndex)}
className={cn(
"inline-block overflow-visible whitespace-pre",
fontClassName
)}
style={{
...gradientStyle,
fontSize:
typeof fontSize === "number"
? `${fontSize}px`
: fontSize,
lineHeight: 1.4,
paddingLeft: autoPaddingX,
paddingRight: autoPaddingX,
paddingTop: autoPaddingY,
paddingBottom: autoPaddingY,
marginLeft: autoMarginX,
marginRight: autoMarginX,
marginTop: autoMarginY,
marginBottom: autoMarginY,
}}
>
{character}
</motion.span>
)
})}
{wordIdx < words.length - 1 && (
<span
className="inline-block"
style={{
fontSize:
typeof fontSize === "number"
? `${fontSize}px`
: fontSize,
width: "0.25em",
}}
>
</span>
)}
</span>
)
})}
</motion.div>
)
}
)
TextWritingEffect.displayName = "TextWritingEffect"
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | Required | The string of text to animate. |
| animationStyle | "write" | "reveal" | "bounce" | "wave" | "typewriter" | "fade" | "scale" | "slide" | "write" | The motion transition style. write triggers SVG vector path outline tracing. |
| fontSize | number | 64 | Font size in pixels for all animation styles (including SVG write mode). |
| speed | number | 2 | Total animation duration in seconds. |
| gradientColors | string[] | undefined | An array of hex or HSL color strings to construct a smooth linear gradient. |
| color | string | "currentColor" | Solid text color fallback when gradientColors is omitted. |
<TextWritingEffect
text="Mutasim Fuad Rimu"
animationStyle="write"
speed={2}
fontSize={68}
fontClassName={pacifico.className}
gradientColors={["#10b981", "#6366f1"]}
/><TextWritingEffect
text="Spring Bounce Motion"
animationStyle="bounce"
fontSize={64}
speed={1.8}
color="#ec4899"
fontClassName={lobster.className}
/><TextWritingEffect
text="3D Fluid Wave Animation"
animationStyle="wave"
fontSize={58}
gradientColors={["#a855f7", "#ec4899", "#f43f5e"]}
fontClassName={greatVibes.className}
/>