skills/awwwards-animations/references/text-effects.md

18 KiB

Advanced Text Effects

Premium text animations beyond basic reveals: glitch, kinetic typography, morphing, distortion, and more.

Table of Contents


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