Overview

Text that arrives a word or a letter at a time. Five styles: blur (words rise out of a blur), chars (the same, letter by letter), mask (words slide up from behind their baseline), chat (left to right, like a message being typed) and pop (words spring in from small). It can start on mount or when it scrolls into view, the whole reveal is capped at about a second however long the text is, and screen readers get the full sentence at once.


Installation

Use the following command to install the component:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/text-reveal.json

Install the following dependencies:

$ pnpm add motion

Add util file

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy and paste the following code into your project.

components/ui/text-reveal.tsx
tsx
"use client";
import React, { useRef } from "react";import { motion, useInView, useReducedMotion, type Variants } from "motion/react";import { cn } from "@/lib/utils";
export type TextRevealVariant = "blur" | "blur-sm" | "chars" | "mask" | "chat" | "pop";
export type TextRevealProps = {  text: string;  /**   * - `blur`: words rise out of a blur (`blur-sm` is an alias)   * - `chars`: the same, letter by letter   * - `mask`: words slide up from behind a baseline   * - `chat`: words arrive left to right like a typed message   * - `pop`: words spring in from small   */  variant?: TextRevealVariant;  /** Start on mount, or when the text scrolls into view. */  trigger?: "mount" | "inView";  /** Seconds before the first unit. */  delay?: number;  /** Seconds between units (words or letters). */  delayPerWord?: number;  /** Seconds per unit. */  duration?: number;  as?: "span" | "div" | "p" | "h1" | "h2" | "h3";  className?: string;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
function unitVariants(variant: TextRevealVariant, duration: number): Variants {  const t = (extra: object = {}) => ({ duration, ease: EASE_OUT, ...extra });  switch (variant) {    case "chars":      return {        hidden: { opacity: 0, y: "0.35em", filter: "blur(6px)" },        visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: t() },      };    case "mask":      return {        hidden: { y: "110%", rotate: 4 },        visible: { y: 0, rotate: 0, transition: t({ duration: duration * 1.4 }) },      };    case "chat":      return {        hidden: { opacity: 0, x: "-0.3em", filter: "blur(2px)" },        visible: { opacity: 1, x: 0, filter: "blur(0px)", transition: t() },      };    case "pop":      return {        hidden: { opacity: 0, scale: 0.6, y: "0.2em" },        visible: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", bounce: 0.45, duration: duration * 1.6 } },      };    default:      return {        hidden: { opacity: 0, y: "0.3em", filter: "blur(8px)" },        visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: t() },      };  }}
export const TextReveal = ({  text,  variant = "blur",  trigger = "mount",  delay = 0,  delayPerWord,  duration = 0.5,  as = "span",  className,}: TextRevealProps) => {  const reduce = useReducedMotion();  const ref = useRef<HTMLElement>(null);  const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });  const Tag = motion[as] as typeof motion.span;  const words = text.trim().split(/\s+/);  const byChar = variant === "chars";  const units = byChar ? text.replace(/\s+/g, " ").trim().length : words.length;  // Keep long strings from dragging: the whole reveal stays under ~1.2s.  const step = Math.min(delayPerWord ?? (byChar ? 0.025 : 0.06), 1.2 / Math.max(units, 1));  const show = trigger === "mount" || inView;
  if (reduce) {    const Plain = as;    return (      <Plain ref={ref as React.Ref<never>} className={className}>        {text}      </Plain>    );  }
  const variants = unitVariants(variant, duration);
  return (    <Tag      ref={ref}      aria-label={text}      initial="hidden"      animate={show ? "visible" : "hidden"}      transition={{ staggerChildren: step, delayChildren: delay }}      className={cn("inline-block", className)}    >      {words.map((word, w) => {        const space = w < words.length - 1 ? " " : "";        if (byChar) {          // Each word stays unbreakable; its letters animate individually.          return (            <span key={w} aria-hidden className="inline-block whitespace-nowrap">              {[...(word + space)].map((ch, c) => (                <motion.span key={c} variants={variants} className="inline-block whitespace-pre">                  {ch}                </motion.span>              ))}            </span>          );        }        if (variant === "mask") {          return (            <span key={w} aria-hidden className="inline-block overflow-hidden pb-[0.12em] align-top -mb-[0.12em]">              <motion.span variants={variants} className="inline-block origin-bottom-left whitespace-pre">                {word + space}              </motion.span>            </span>          );        }        return (          <motion.span key={w} aria-hidden variants={variants} className="inline-block whitespace-pre">            {word + space}          </motion.span>        );      })}    </Tag>  );};

Update the import paths to match your project setup.

Basic Usage

tsx
import { TextReveal } from "@/components/ui/text-reveal";
<TextReveal  as="h2"  variant="mask"  trigger="inView"  text="Good motion gives a change a place to come from."  className="text-3xl font-semibold tracking-tight"/>

To replay, change its key.


Props

PropTypeRequiredDefaultDescription
textstringYes—The text to reveal.
variant"blur" | "chars" | "mask" | "chat" | "pop"No"blur"Reveal style. The old blur-sm name still works as an alias for blur.
trigger"mount" | "inView"No"mount"Start immediately, or the first time the text scrolls into view.
as"span" | "div" | "p" | "h1" | "h2" | "h3"No"span"Element to render.
delaynumberNo0Seconds before the first word or letter.
delayPerWordnumberNo—Seconds between units. Defaults to 0.06 for words and 0.025 for letters, shortened for long text.
durationnumberNo0.5Seconds per unit.
classNamestringNo—Typography classes.

Best Practices

  1. Use it for headlines and one-off moments, not body copy people need to read straight away.
  2. chars suits short lines; for anything longer, blur or mask reads better.
  3. Use trigger="inView" below the fold so the reveal isn't over before anyone sees it.
  4. Reduced motion renders the text immediately.