Animated Text

Staggered text reveal for headings, pull quotes, and short supporting copy.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/animated-text.json
bash

Code

"use client";

import * as React from "react";

import { cn } from "../../lib/utils";

const GLYPH_SEGMENTER = new Intl.Segmenter(undefined, {
  granularity: "grapheme",
});

const ASCII_RANDOM_CHARACTERS = Array.from({ length: 94 }, (_, index) =>
  String.fromCodePoint(index + 33),
).join("");
const TERMINAL_RANDOM_CHARACTERS = "│┃─━┄┅┈┉┌┐└┘├┤┬┴┼╭╮╯╰╱╲╳";
const BLOCK_RANDOM_CHARACTERS = "░▒▓█▌▐▀▄■□▪▫▖▗▘▙▚▛▜▝▞▟";
const UNICODE_SYMBOL_RANDOM_CHARACTERS = "◆◇◈○●◎◉◌◍◐◑◒◓◔◕◢◣◤◥◦※✦✧✱✶✷✹";
const MATRIX_RANDOM_CHARACTERS = `${ASCII_RANDOM_CHARACTERS}${TERMINAL_RANDOM_CHARACTERS}${BLOCK_RANDOM_CHARACTERS}${UNICODE_SYMBOL_RANDOM_CHARACTERS}`;

export const ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS = {
  ascii: ASCII_RANDOM_CHARACTERS,
  binary: "01",
  blocks: BLOCK_RANDOM_CHARACTERS,
  matrix: MATRIX_RANDOM_CHARACTERS,
  symbols: UNICODE_SYMBOL_RANDOM_CHARACTERS,
  terminal: TERMINAL_RANDOM_CHARACTERS,
} as const;

const DEFAULT_RANDOM_CHARACTERS = ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS.matrix;

type AnimatedTextSplit = "character" | "word";
export type AnimatedTextVariant =
  | "decipher"
  | "matrix"
  | "reveal"
  | "terminal"
  | "typewriter";
export type AnimatedTextDirection = "center-out" | "end" | "random" | "start";
export type AnimatedTextRandomCharacterPreset =
  keyof typeof ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS;

type SegmentFrame = {
  content: string;
  isRevealed: boolean;
  key: string;
  style?: React.CSSProperties;
};

export type AnimatedTextProps = React.ComponentPropsWithoutRef<"p"> & {
  cursor?: boolean;
  cursorChar?: string;
  direction?: AnimatedTextDirection;
  duration?: number;
  randomCharacters?: string;
  randomCharactersPreset?: AnimatedTextRandomCharacterPreset;
  randomness?: number;
  splitBy?: AnimatedTextSplit;
  stagger?: number;
  text: string;
  variant?: AnimatedTextVariant;
};

function getSegments(text: string, splitBy: AnimatedTextSplit): string[] {
  if (splitBy === "character") {
    return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);
  }

  return text.match(/\S+\s*/g) ?? [];
}

function getGlyphs(text: string): string[] {
  return Array.from(GLYPH_SEGMENTER.segment(text), ({ segment }) => segment);
}

function getRandomMatrixGlyph(randomCharacters: string): string {
  const glyphs = getGlyphs(randomCharacters);

  return glyphs[Math.floor(Math.random() * glyphs.length)] ?? glyphs[0] ?? "0";
}

function getResolvedRandomCharacters(
  randomCharacters: string | undefined,
  randomCharactersPreset: AnimatedTextRandomCharacterPreset,
): string {
  if (randomCharacters && randomCharacters.length > 0) {
    return randomCharacters;
  }

  return (
    ANIMATED_TEXT_RANDOM_CHARACTER_PRESETS[randomCharactersPreset] ??
    DEFAULT_RANDOM_CHARACTERS
  );
}

function getCursorToneClass(variant: AnimatedTextVariant): string {
  return variant === "matrix" || variant === "decipher"
    ? "text-primary"
    : "text-foreground";
}

function buildRevealFrames(
  segments: string[],
  stagger: number,
): SegmentFrame[] {
  return segments.map((segment, index) => ({
    content: segment,
    isRevealed: true,
    key: `${segment}-${index}`,
    style: {
      animationDelay: `${index * stagger}ms`,
    },
  }));
}

function buildIndexOrder(
  direction: AnimatedTextDirection,
  length: number,
): number[] {
  if (direction === "end") {
    return Array.from({ length }, (_, index) => length - index - 1);
  }

  if (direction === "random") {
    return Array.from({ length }, (_, index) => index).sort(
      () => Math.random() - 0.5,
    );
  }

  if (direction === "center-out") {
    const center = (length - 1) / 2;

    return Array.from({ length }, (_, index) => index).sort((left, right) => {
      const leftDistance = Math.abs(left - center);
      const rightDistance = Math.abs(right - center);

      if (leftDistance === rightDistance) {
        return left - right;
      }

      return leftDistance - rightDistance;
    });
  }

  return Array.from({ length }, (_, index) => index);
}

function buildRevealPlan(
  direction: AnimatedTextDirection,
  length: number,
  randomness: number,
): number[] {
  const orderedIndices = buildIndexOrder(direction, length);
  const revealPlan = Array.from({ length }, () => 0);
  const jitterRange = Math.max(0, Math.round(randomness * 4));

  orderedIndices.forEach((segmentIndex, revealIndex) => {
    const jitter =
      jitterRange > 0 ? Math.floor(Math.random() * (jitterRange + 1)) : 0;
    revealPlan[segmentIndex] = revealIndex + jitter;
  });

  return revealPlan;
}

function useRevealProgress(active: boolean, length: number, stagger: number) {
  const [progress, setProgress] = React.useState(0);

  React.useEffect(() => {
    if (!active) {
      setProgress(length);
      return;
    }

    setProgress(0);

    const revealInterval = window.setInterval(
      () => {
        setProgress((current) => {
          if (current >= length + 4) {
            window.clearInterval(revealInterval);
            return current;
          }

          return current + 1;
        });
      },
      Math.max(16, stagger),
    );

    return () => {
      window.clearInterval(revealInterval);
    };
  }, [active, length, stagger]);

  return progress;
}

function useMatrixFrame({
  active,
  progress,
  randomCharacters,
  revealPlan,
  segments,
}: {
  active: boolean;
  progress: number;
  randomCharacters: string;
  revealPlan: number[];
  segments: string[];
}) {
  const [matrixFrame, setMatrixFrame] = React.useState(() =>
    segments.map(() => getRandomMatrixGlyph(randomCharacters)),
  );

  React.useEffect(() => {
    if (!active) {
      return;
    }

    setMatrixFrame(segments.map(() => getRandomMatrixGlyph(randomCharacters)));

    const scrambleInterval = window.setInterval(() => {
      setMatrixFrame((current) =>
        current.map((glyph, index) => {
          const isWhitespace = /^\s+$/.test(segments[index] ?? "");
          const isRevealed = progress >= (revealPlan[index] ?? 0);

          if (isWhitespace || isRevealed) {
            return glyph;
          }

          return getRandomMatrixGlyph(randomCharacters);
        }),
      );
    }, 48);

    return () => {
      window.clearInterval(scrambleInterval);
    };
  }, [active, progress, randomCharacters, revealPlan, segments]);

  return matrixFrame;
}

function buildOldSchoolFrames({
  matrixFrame,
  progress,
  randomCharacters,
  revealPlan,
  segments,
  variant,
}: {
  matrixFrame: string[];
  progress: number;
  randomCharacters: string;
  revealPlan: number[];
  segments: string[];
  variant: Exclude<AnimatedTextVariant, "reveal">;
}): SegmentFrame[] {
  return segments.map((segment, index) => {
    const isWhitespace = /^\s+$/.test(segment);
    const revealStep = revealPlan[index] ?? 0;
    const isRevealed = progress >= revealStep;

    let content = "";
    if (variant === "matrix" || variant === "decipher") {
      content = isWhitespace
        ? segment
        : isRevealed
          ? segment
          : (matrixFrame[index] ?? getRandomMatrixGlyph(randomCharacters));
    } else if (isRevealed) {
      content = segment;
    }

    return {
      content,
      isRevealed,
      key: `${segment}-${index}`,
    };
  });
}

function useAnimatedTextFrames({
  direction,
  randomCharacters,
  randomness,
  segments,
  stagger,
  variant,
}: {
  direction: AnimatedTextDirection;
  randomCharacters: string;
  randomness: number;
  segments: string[];
  stagger: number;
  variant: AnimatedTextVariant;
}): SegmentFrame[] {
  const isOldSchool = variant !== "reveal";
  const revealPlan = React.useMemo(
    () =>
      isOldSchool
        ? buildRevealPlan(direction, segments.length, randomness)
        : Array.from({ length: segments.length }, (_, index) => index),
    [direction, isOldSchool, randomness, segments.length],
  );
  const progress = useRevealProgress(isOldSchool, segments.length, stagger);
  const matrixFrame = useMatrixFrame({
    active: variant === "matrix" || variant === "decipher",
    progress,
    randomCharacters,
    revealPlan,
    segments,
  });

  return React.useMemo(() => {
    if (!isOldSchool) {
      return buildRevealFrames(segments, stagger);
    }

    return buildOldSchoolFrames({
      matrixFrame,
      progress,
      randomCharacters,
      revealPlan,
      segments,
      variant,
    });
  }, [
    isOldSchool,
    matrixFrame,
    progress,
    randomCharacters,
    revealPlan,
    segments,
    stagger,
    variant,
  ]);
}

function getSegmentClasses(
  variant: AnimatedTextVariant,
  isRevealed: boolean,
): string {
  if (variant === "reveal") {
    return "inline-block whitespace-pre opacity-0 [animation-duration:var(--vllnt-animated-text-duration)] [animation-fill-mode:forwards] [animation-name:vllnt-animated-text-reveal] [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]";
  }

  if (variant === "matrix" || variant === "decipher") {
    return cn(
      "inline-block whitespace-pre font-mono tracking-[0.08em] transition-colors duration-150",
      isRevealed ? "text-foreground" : "text-primary/75",
    );
  }

  return "inline-block whitespace-pre font-mono";
}

function getContainerClasses(variant: AnimatedTextVariant): string {
  if (variant === "matrix" || variant === "decipher") {
    return "flex flex-wrap font-mono leading-relaxed tracking-[0.08em]";
  }

  if (variant === "terminal" || variant === "typewriter") {
    return "flex flex-wrap font-mono leading-relaxed";
  }

  return "flex flex-wrap leading-relaxed";
}

function AnimatedTextCursor({
  cursorChar,
  cursorToneClass,
}: {
  cursorChar: string;
  cursorToneClass: string;
}) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "ml-0.5 inline-block whitespace-pre font-mono [animation:vllnt-terminal-cursor-blink_1s_steps(1,end)_infinite]",
        cursorToneClass,
      )}
    >
      {cursorChar}
    </span>
  );
}

export const AnimatedText = React.forwardRef<
  HTMLParagraphElement,
  AnimatedTextProps
>(
  (
    {
      className,
      cursor = true,
      cursorChar = "█",
      direction = "start",
      duration = 600,
      randomCharacters,
      randomCharactersPreset = "matrix",
      randomness = 0,
      splitBy = "word",
      stagger = 70,
      text,
      variant = "terminal",
      ...props
    },
    ref,
  ) => {
    const resolvedRandomCharacters = getResolvedRandomCharacters(
      randomCharacters,
      randomCharactersPreset,
    );
    const resolvedSplitBy = variant === "reveal" ? splitBy : "character";
    const segments = React.useMemo(
      () => getSegments(text, resolvedSplitBy),
      [resolvedSplitBy, text],
    );
    const segmentFrames = useAnimatedTextFrames({
      direction,
      randomCharacters: resolvedRandomCharacters,
      randomness,
      segments,
      stagger,
      variant,
    });
    const showCursor =
      cursor &&
      variant !== "reveal" &&
      segmentFrames.some((frame) => !frame.isRevealed);
    const cursorToneClass = getCursorToneClass(variant);

    return (
      <p
        aria-label={text}
        className={cn(getContainerClasses(variant), className)}
        ref={ref}
        style={{
          ["--vllnt-animated-text-duration" as string]: `${duration}ms`,
        }}
        {...props}
      >
        {segmentFrames.map((segmentFrame) => (
          <span
            aria-hidden="true"
            className={getSegmentClasses(variant, segmentFrame.isRevealed)}
            key={segmentFrame.key}
            style={segmentFrame.style}
          >
            {segmentFrame.content}
          </span>
        ))}
        {showCursor ? (
          <AnimatedTextCursor
            cursorChar={cursorChar}
            cursorToneClass={cursorToneClass}
          />
        ) : null}
      </p>
    );
  },
);

AnimatedText.displayName = "AnimatedText";
typescript

Dependencies

  • @vllnt/ui@^0.2.1