Letter Draw

LetterDraw is a customizable SVG text animation that reveals text using a stroke-draw effect. It’s theme-aware, responsive, and ideal for hero sections or modern UI headings.

next-themes
Fork UI

Installation

Install the required dependencies:

npm install next-themes
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/ui/letter-draw.tsx
"use client";
import { useTheme } from "next-themes";
import React, { useRef, useEffect, useId, useState } from "react";
import gsap from "gsap";

interface LetterDrawProps {
  text?: string;
  fontSize?: number;
  strokeColor?: string;
  strokeWidth?: number;
  duration?: number;
  glow?: boolean;
}

export default function LetterDraw({
  text = "Fork UI",
  fontSize = 180,
  strokeColor,
  strokeWidth = 4,
  duration = 1.2,
  glow = true,
}: LetterDrawProps) {
  const { resolvedTheme } = useTheme();
  const svgRef = useRef<SVGSVGElement | null>(null);
  const letterRefs = useRef<(SVGTextElement | null)[]>([]);
  const gradientId = useId();
  const [positions, setPositions] = useState<number[]>([]);

  useEffect(() => {
    const setup = async () => {
      await document.fonts.ready;

      let totalWidth = 0;
      const widths: number[] = [];

      letterRefs.current.forEach((letter) => {
        if (!letter) return;
        const w = letter.getComputedTextLength();
        widths.push(w);
        totalWidth += w;
      });

      let startX = -totalWidth / 2;
      const pos = widths.map((w) => {
        const x = startX + w / 2;
        startX += w;
        return x;
      });

      setPositions(pos);

      const tl = gsap.timeline({
        repeat: -1,
        repeatDelay: 1,
      });

      letterRefs.current.forEach((letter, i) => {
        if (!letter) return;

        const length = letter.getComputedTextLength() * 10;

        gsap.set(letter, {
          strokeDasharray: length,
          strokeDashoffset: length,
          y: 40,
          opacity: 0,
        });

        tl.to(
          letter,
          {
            strokeDashoffset: 0,
            y: 0,
            opacity: 1,
            duration,
            ease: "power3.out",
          },
          i * 0.15,
        );
      });

      // reset letters after drawing
      tl.to(
        letterRefs.current,
        {
          strokeDashoffset: (i, target: any) =>
            target.getComputedTextLength() * 10,
          opacity: 0,
          y: 40,
          duration: 0.6,
          ease: "power2.in",
        },
        "+=1",
      );
    };

    setup();
  }, [text, duration]);

  useEffect(() => {
    const svg = svgRef.current;
    if (!svg) return;

    const handleMove = (e: MouseEvent) => {
      const rect = svg.getBoundingClientRect();
      const x = (e.clientX - rect.left - rect.width / 2) / 20;
      const y = (e.clientY - rect.top - rect.height / 2) / 20;

      gsap.to(svg, {
        rotateY: x,
        rotateX: -y,
        transformPerspective: 800,
        transformOrigin: "center",
        ease: "power2.out",
        duration: 0.5,
      });
    };

    const reset = () => {
      gsap.to(svg, {
        rotateX: 0,
        rotateY: 0,
        duration: 0.8,
        ease: "power3.out",
      });
    };

    svg.addEventListener("mousemove", handleMove);
    svg.addEventListener("mouseleave", reset);

    return () => {
      svg.removeEventListener("mousemove", handleMove);
      svg.removeEventListener("mouseleave", reset);
    };
  }, []);

  const finalStroke =
    strokeColor ||
    `url(#${resolvedTheme === "dark" ? gradientId + "-dark" : gradientId})`;

  return (
    <svg
      ref={svgRef}
      viewBox="-600 -200 1200 400"
      className="w-full h-auto cursor-pointer"
      preserveAspectRatio="xMidYMid meet"
      style={{ transformStyle: "preserve-3d" }}
    >
      <defs>
        <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%">
          <stop offset="0%" stopColor="#000" />
          <stop offset="100%" stopColor="#555" />
        </linearGradient>


        <linearGradient id={`${gradientId}-dark`} x1="0%" y1="0%" x2="100%">
          <stop offset="0%" stopColor="#fff" />
          <stop offset="100%" stopColor="#aaa" />
        </linearGradient>

        {glow && (
          <filter id="glow">
            <feGaussianBlur stdDeviation="4" result="blur" />
            <feMerge>
              <feMergeNode in="blur" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>
        )}
      </defs>

      <g
        textAnchor="middle"
        dominantBaseline="middle"
        fontSize={fontSize}
        fontFamily="inherit"
      >
        {text.split("").map((char, i) => (
          <text
            key={i}
            ref={(el) => {
              letterRefs.current[i] = el;
            }}
            x={positions[i] || 0}
            y={0}
            fill="transparent"
            stroke={finalStroke}
            strokeWidth={strokeWidth}
            strokeLinecap="round"
            strokeLinejoin="round"
            filter={glow ? "url(#glow)" : undefined}
            className="select-none font-bebas tracking-widest"
          >
            {char}
          </text>
        ))}
      </g>
    </svg>
  );
}

Usecase

Fork UI

* LetterDraw is an animated SVG text component that creates a smooth stroke-drawing effect, making text appear as if it’s being written in real time.