18 KiB
18 KiB
Advanced Text Effects
Premium text animations beyond basic reveals: glitch, kinetic typography, morphing, distortion, and more.
Table of Contents
- Glitch Text
- Kinetic Typography
- Morphing Text
- Typewriter with Distortion
- Text Explosion
- Circular & Spiral Text
- Brutalist Text
- Minimal Text
- Advanced Scramble
- Stroke Text Drawing
Glitch Text
RGB split, scan lines, and random character swapping.
'use client'
import { useRef, useEffect, useState } from 'react'
import { gsap } from '@/lib/gsap'
export function GlitchText({ text, intensity = 'medium' }: {
text: string; intensity?: 'low' | 'medium' | 'high'
}) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = containerRef.current!
const layers = el.querySelectorAll('.glitch-layer')
const offsets = { low: 2, medium: 5, high: 12 }
const offset = offsets[intensity]
const tl = gsap.timeline({ repeat: -1, repeatDelay: 2 })
// Glitch burst
tl.to(layers[0], { x: -offset, duration: 0.05, ease: 'none' }, 0)
.to(layers[0], { x: offset, duration: 0.05, ease: 'none' }, 0.05)
.to(layers[0], { x: 0, duration: 0.05, ease: 'none' }, 0.1)
.to(layers[1], { x: offset, duration: 0.05, ease: 'none' }, 0.02)
.to(layers[1], { x: -offset, duration: 0.05, ease: 'none' }, 0.07)
.to(layers[1], { x: 0, duration: 0.05, ease: 'none' }, 0.12)
// Clip-path glitch
.to(el, {
clipPath: `inset(${Math.random() * 40}% 0 ${Math.random() * 40}% 0)`,
duration: 0.05,
}, 0.15)
.to(el, { clipPath: 'inset(0 0 0 0)', duration: 0.05 }, 0.2)
return () => { tl.kill() }
}, [intensity])
return (
<div ref={containerRef} className="relative font-mono text-5xl font-black">
<span className="relative z-10">{text}</span>
<span
className="glitch-layer absolute inset-0 text-cyan-400 mix-blend-multiply"
style={{ clipPath: 'inset(0 0 0 0)' }}
aria-hidden
>
{text}
</span>
<span
className="glitch-layer absolute inset-0 text-red-400 mix-blend-multiply"
style={{ clipPath: 'inset(0 0 0 0)' }}
aria-hidden
>
{text}
</span>
{/* Scan lines overlay */}
<div
className="absolute inset-0 pointer-events-none z-20"
style={{
background: 'repeating-linear-gradient(0deg, rgba(0,0,0,0.1) 0px, rgba(0,0,0,0.1) 1px, transparent 1px, transparent 3px)',
}}
aria-hidden
/>
</div>
)
}
CSS-Only Glitch (Lightweight Alternative)
.glitch {
position: relative;
font-family: monospace;
font-weight: 900;
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
}
.glitch::before {
color: cyan;
animation: glitch-shift 3s infinite;
clip-path: inset(20% 0 30% 0);
mix-blend-mode: multiply;
}
.glitch::after {
color: red;
animation: glitch-shift 3s infinite reverse;
clip-path: inset(50% 0 10% 0);
mix-blend-mode: multiply;
}
@keyframes glitch-shift {
0%, 90%, 100% { transform: translateX(0); }
92% { transform: translateX(-5px); }
94% { transform: translateX(5px); }
96% { transform: translateX(-3px); }
98% { transform: translateX(3px); }
}
Kinetic Typography
Letters that orbit, wave, and bounce independently.
'use client'
import { useRef } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function WaveText({ text, amplitude = 20, frequency = 0.15, speed = 2 }: {
text: string; amplitude?: number; frequency?: number; speed?: number
}) {
const containerRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
const chars = containerRef.current!.querySelectorAll('.wave-char')
chars.forEach((char, i) => {
gsap.to(char, {
y: `+=${amplitude}`,
duration: speed,
repeat: -1,
yoyo: true,
ease: 'sine.inOut',
delay: i * frequency,
})
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="text-6xl font-bold flex">
{text.split('').map((char, i) => (
<span key={i} className="wave-char inline-block">
{char === ' ' ? '\u00A0' : char}
</span>
))}
</div>
)
}
Orbiting Letters
'use client'
import { useRef, useEffect } from 'react'
export function OrbitText({ text, radius = 120, speed = 0.01 }: {
text: string; radius?: number; speed?: number
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const w = canvas.offsetWidth
const h = canvas.offsetHeight
canvas.width = w * 2
canvas.height = h * 2
ctx.scale(2, 2)
let time = 0
const chars = text.split('')
const animate = () => {
ctx.clearRect(0, 0, w, h)
ctx.font = 'bold 24px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
chars.forEach((char, i) => {
const angle = (i / chars.length) * Math.PI * 2 + time
const x = w / 2 + Math.cos(angle) * radius
const y = h / 2 + Math.sin(angle) * radius * 0.4 // Elliptical
const scale = 0.6 + (Math.sin(angle) + 1) * 0.2 // Depth
ctx.save()
ctx.globalAlpha = 0.4 + (Math.sin(angle) + 1) * 0.3
ctx.font = `bold ${24 * scale}px monospace`
ctx.fillStyle = `hsl(${(i / chars.length) * 360}, 70%, 65%)`
ctx.fillText(char, x, y)
ctx.restore()
})
time += speed
animRef.current = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(animRef.current)
}, [text, radius, speed])
return <canvas ref={canvasRef} className="w-full h-full" />
}
Morphing Text
Smooth transition between words.
'use client'
import { useRef, useEffect, useState } from 'react'
import { gsap } from '@/lib/gsap'
export function MorphText({ words, interval = 3000 }: { words: string[]; interval?: number }) {
const text1Ref = useRef<HTMLSpanElement>(null)
const text2Ref = useRef<HTMLSpanElement>(null)
const [index, setIndex] = useState(0)
useEffect(() => {
const el1 = text1Ref.current!
const el2 = text2Ref.current!
el1.textContent = words[index]
el2.textContent = words[(index + 1) % words.length]
const tl = gsap.timeline({
onComplete: () => setIndex((index + 1) % words.length),
delay: interval / 1000,
})
tl.to(el1, {
opacity: 0,
filter: 'blur(8px)',
duration: 0.8,
ease: 'power2.in',
})
.fromTo(el2, {
opacity: 0,
filter: 'blur(8px)',
}, {
opacity: 1,
filter: 'blur(0px)',
duration: 0.8,
ease: 'power2.out',
}, '-=0.4')
return () => { tl.kill() }
}, [index, words, interval])
return (
<div className="relative text-6xl font-bold">
<span ref={text1Ref} className="absolute inset-0" />
<span ref={text2Ref} className="absolute inset-0 opacity-0" />
{/* Invisible spacer */}
<span className="invisible">{words.reduce((a, b) => a.length > b.length ? a : b)}</span>
</div>
)
}
Typewriter with Distortion
Classic typewriter with noise displacement.
'use client'
import { useState, useEffect, useRef } from 'react'
import { gsap } from '@/lib/gsap'
export function TypewriterDistortion({ text, speed = 50 }: { text: string; speed?: number }) {
const [displayed, setDisplayed] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const charIndex = useRef(0)
useEffect(() => {
const timer = setInterval(() => {
if (charIndex.current < text.length) {
setDisplayed(text.slice(0, charIndex.current + 1))
charIndex.current++
// Distortion shake on each character
if (containerRef.current) {
gsap.fromTo(containerRef.current, {
x: (Math.random() - 0.5) * 4,
y: (Math.random() - 0.5) * 2,
skewX: (Math.random() - 0.5) * 2,
}, {
x: 0, y: 0, skewX: 0,
duration: 0.15,
ease: 'power2.out',
})
}
} else {
clearInterval(timer)
}
}, speed)
return () => clearInterval(timer)
}, [text, speed])
return (
<div ref={containerRef} className="font-mono text-3xl">
{displayed}
<span className="animate-pulse">|</span>
</div>
)
}
Text Explosion
Characters scatter with physics-like motion.
'use client'
import { useRef } from 'react'
import { motion } from 'motion/react'
export function TextExplosion({ text, trigger = false }: { text: string; trigger?: boolean }) {
const chars = text.split('')
return (
<div className="relative text-5xl font-bold flex justify-center">
{chars.map((char, i) => {
const angle = ((i / chars.length) * 360 + Math.random() * 60) * (Math.PI / 180)
const distance = 200 + Math.random() * 300
const targetX = Math.cos(angle) * distance
const targetY = Math.sin(angle) * distance
return (
<motion.span
key={i}
className="inline-block"
animate={trigger ? {
x: targetX,
y: targetY,
rotate: Math.random() * 720 - 360,
opacity: 0,
scale: 0,
} : {
x: 0, y: 0, rotate: 0, opacity: 1, scale: 1,
}}
transition={{
duration: 0.8 + Math.random() * 0.4,
ease: [0.16, 1, 0.3, 1],
delay: i * 0.02,
}}
>
{char === ' ' ? '\u00A0' : char}
</motion.span>
)
})}
</div>
)
}
Circular & Spiral Text
SVG textPath for text on a circular path.
'use client'
import { motion } from 'motion/react'
export function CircularText({ text, radius = 100, speed = 20 }: {
text: string; radius?: number; speed?: number
}) {
const id = `circle-path-${Math.random().toString(36).slice(2)}`
return (
<motion.svg
width={radius * 2 + 40}
height={radius * 2 + 40}
viewBox={`0 0 ${radius * 2 + 40} ${radius * 2 + 40}`}
animate={{ rotate: 360 }}
transition={{ duration: speed, repeat: Infinity, ease: 'linear' }}
>
<defs>
<path
id={id}
d={`M ${radius + 20}, ${radius + 20} m -${radius}, 0 a ${radius},${radius} 0 1,1 ${radius * 2},0 a ${radius},${radius} 0 1,1 -${radius * 2},0`}
fill="none"
/>
</defs>
<text className="fill-current text-sm font-mono tracking-[0.3em] uppercase">
<textPath href={`#${id}`}>{text}</textPath>
</text>
</motion.svg>
)
}
Spiral Text (Canvas)
'use client'
import { useRef, useEffect } from 'react'
export function SpiralText({ text, turns = 3 }: { text: string; turns?: number }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
const w = canvas.offsetWidth
const h = canvas.offsetHeight
canvas.width = w * 2
canvas.height = h * 2
ctx.scale(2, 2)
const cx = w / 2, cy = h / 2
const chars = text.split('')
const maxRadius = Math.min(w, h) * 0.4
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
chars.forEach((char, i) => {
const t = i / chars.length
const angle = t * Math.PI * 2 * turns
const radius = 20 + t * (maxRadius - 20)
const x = cx + Math.cos(angle) * radius
const y = cy + Math.sin(angle) * radius
ctx.save()
ctx.translate(x, y)
ctx.rotate(angle + Math.PI / 2)
ctx.font = `${14 + t * 12}px monospace`
ctx.fillStyle = `hsl(${t * 360}, 70%, 60%)`
ctx.fillText(char, 0, 0)
ctx.restore()
})
}, [text, turns])
return <canvas ref={canvasRef} className="w-full h-full" />
}
Brutalist Text
Oversized mono, extreme overlap, aggressive rotation, mixed scales.
'use client'
import { motion } from 'motion/react'
export function BrutalistHeading({ text }: { text: string }) {
const words = text.split(' ')
return (
<div className="relative overflow-hidden p-4">
{words.map((word, i) => (
<motion.div
key={i}
className="font-mono font-black uppercase leading-[0.85]"
style={{
fontSize: `${8 + Math.random() * 10}vw`,
marginLeft: `${(Math.random() - 0.5) * 10}vw`,
marginTop: i > 0 ? '-2vw' : '0',
mixBlendMode: i % 2 === 0 ? 'normal' : 'difference',
}}
initial={{ x: i % 2 === 0 ? -200 : 200, rotate: (Math.random() - 0.5) * 15 }}
whileInView={{ x: 0, rotate: (Math.random() - 0.5) * 3 }}
viewport={{ once: true }}
transition={{ duration: 0.3, ease: 'linear' }}
>
{word}
</motion.div>
))}
</div>
)
}
Minimal Text
Ultra-subtle fades, weight transitions, tracking animations.
'use client'
import { motion } from 'motion/react'
export function MinimalText({ text }: { text: string }) {
return (
<motion.h1
className="text-4xl font-extralight tracking-[0.3em] uppercase"
initial={{ opacity: 0, letterSpacing: '0.5em' }}
whileInView={{ opacity: 1, letterSpacing: '0.3em' }}
viewport={{ once: true }}
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
>
{text}
</motion.h1>
)
}
Weight Transition Text
'use client'
import { useRef } from 'react'
import { gsap, useGSAP } from '@/lib/gsap'
export function WeightReveal({ text }: { text: string }) {
const containerRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
const chars = containerRef.current!.querySelectorAll('span')
gsap.fromTo(chars, {
fontWeight: 100,
opacity: 0.3,
}, {
fontWeight: 700,
opacity: 1,
duration: 1,
stagger: 0.05,
ease: 'power2.out',
scrollTrigger: {
trigger: containerRef.current,
start: 'top 80%',
},
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="text-5xl tracking-wide">
{text.split('').map((char, i) => (
<span key={i} className="inline-block" style={{ fontVariationSettings: '"wght" 100' }}>
{char === ' ' ? '\u00A0' : char}
</span>
))}
</div>
)
}
Advanced Scramble
Matrix-style decode and glyph cycling.
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'
const BINARY = '01'
export function MatrixDecode({ text, mode = 'matrix', revealSpeed = 30 }: {
text: string; mode?: 'matrix' | 'binary' | 'glyphs'; revealSpeed?: number
}) {
const [display, setDisplay] = useState('')
const revealed = useRef(0)
const chars = mode === 'binary' ? BINARY : GLYPHS
const scramble = useCallback(() => {
let result = ''
for (let i = 0; i < text.length; i++) {
if (i < revealed.current) {
result += text[i]
} else if (text[i] === ' ') {
result += ' '
} else {
result += chars[Math.floor(Math.random() * chars.length)]
}
}
return result
}, [text, chars])
useEffect(() => {
revealed.current = 0
const scrambleInterval = setInterval(() => setDisplay(scramble()), 50)
const revealInterval = setInterval(() => {
revealed.current++
if (revealed.current > text.length) {
clearInterval(revealInterval)
clearInterval(scrambleInterval)
setDisplay(text)
}
}, revealSpeed)
return () => {
clearInterval(scrambleInterval)
clearInterval(revealInterval)
}
}, [text, revealSpeed, scramble])
return (
<span className="font-mono text-green-400 whitespace-pre">{display}</span>
)
}
Stroke Text Drawing
SVG text with animated stroke.
'use client'
import { motion } from 'motion/react'
export function StrokeText({ text, fontSize = 80, duration = 2 }: {
text: string; fontSize?: number; duration?: number
}) {
return (
<svg width="100%" height={fontSize * 1.5} className="overflow-visible">
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className="font-bold"
style={{
fontSize,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
}}
initial={{ strokeDasharray: 1000, strokeDashoffset: 1000 }}
whileInView={{ strokeDashoffset: 0 }}
viewport={{ once: true }}
transition={{ duration, ease: 'linear' }}
>
{text}
</motion.text>
{/* Fill reveal after stroke */}
<motion.text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="central"
className="font-bold fill-current"
style={{ fontSize }}
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: duration }}
>
{text}
</motion.text>
</svg>
)
}
Choosing the Right Effect
| Context | Effect | Why |
|---|---|---|
| Hero heading | Stroke Drawing or Morph | Dramatic first impression |
| Error/404 page | Glitch | Fits the broken theme |
| Loading/processing | Scramble/Decode | Communicates computation |
| Creative portfolio | Kinetic/Orbit | Shows motion design skill |
| Brand statement | Brutalist or Minimal | Strong identity signal |
| Interactive element | Explosion | Satisfying user feedback |
| Narrative section | Typewriter | Builds anticipation |
| Navigation | Circular | Unique, memorable |