interior[.]dev

Content·10.1

Text Reveal

Words arrive in reading order.

Nobody is given a week to write these, so they ship at eighty percent and stay there for the life of the product.
text-reveal

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useState } from "react";
import { TextReveal } from "@/components/interior/text-reveal";

export function AnswerCard({ answer }: { answer: string }) {
  const [asked, setAsked] = useState(false);

  return (
    <section className="rounded-[14px] border border-stone-200 p-4 dark:border-white/[0.16]">
      <TextReveal
        text={answer}
        play={asked}
        startOnView={false}
        stagger={0.05}
        maxDuration={1.6}
        className="block text-[13.5px] leading-relaxed"
      />

      <button
        type="button"
        onClick={() => setAsked(true)}
        disabled={asked}
        className="mt-3 h-9 rounded-[9px] border border-stone-200 px-3 text-[13px] font-medium text-stone-700 disabled:opacity-50 dark:border-white/[0.16] dark:text-stone-200"
      >
        Ask
      </button>
    </section>
  );
}

Source

components/interior/text-reveal.tsx
"use client";

import { Fragment, useMemo, useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;
const DURATION = 0.6;

const HIDDEN = { opacity: 0, y: 10, filter: "blur(8px)" } as const;
const SHOWN = { opacity: 1, y: 0, filter: "blur(0px)" } as const;

export type TextRevealSplit = "word" | "character";

export type TextRevealUnit = {
  key: string;
  text: string;
  index: number;
};

export type TextRevealGroup = {
  key: string;
  units: TextRevealUnit[];
};

export type UseTextRevealOptions = {
  text: string;
  by?: TextRevealSplit;
  stagger?: number;
  maxDuration?: number;
  startOnView?: boolean;
  play?: boolean;
  once?: boolean;
  amount?: number;
};

export function useTextReveal<T extends HTMLElement = HTMLSpanElement>({
  text,
  by = "word",
  stagger = 0.055,
  maxDuration = 1.6,
  startOnView = true,
  play = true,
  once = true,
  amount = 0.35,
}: UseTextRevealOptions) {
  const ref = useRef<T>(null);
  const inView = useInView(ref, { once, amount });
  const reduced = useReducedMotion();

  const { groups, step, count } = useMemo(() => {
    const words = text.trim().length ? text.trim().split(/\s+/) : [];

    let index = 0;
    const built: TextRevealGroup[] = words.map((word, w) => {
      if (by === "character") {
        return {
          key: `w${w}`,
          units: Array.from(word).map((char, c) => ({
            key: `w${w}c${c}`,
            text: char,
            index: index++,
          })),
        };
      }
      return {
        key: `w${w}`,
        units: [{ key: `w${w}`, text: word, index: index++ }],
      };
    });

    const total = index;
    const span = Math.max(0, maxDuration - DURATION);

    return {
      groups: built,
      count: total,
      step: total > 1 ? Math.min(stagger, span / (total - 1)) : 0,
    };
  }, [text, by, stagger, maxDuration]);

  const started = play && (!startOnView || inView);

  return {
    ref,
    groups,
    step,
    count,
    started,
    reduced: Boolean(reduced),
    duration: count > 1 ? (count - 1) * step + DURATION : DURATION,
  };
}

export type TextRevealProps = UseTextRevealOptions & {
  className?: string;
};

export function TextReveal({
  text,
  by = "word",
  stagger = 0.055,
  maxDuration = 1.6,
  startOnView = true,
  play = true,
  once = true,
  amount = 0.35,
  className = "",
}: TextRevealProps) {
  const { ref, groups, step, started, reduced } = useTextReveal<HTMLSpanElement>(
    { text, by, stagger, maxDuration, startOnView, play, once, amount },
  );

  return (
    <span ref={ref} className={`text-stone-700 dark:text-stone-200 ${className}`}>
      <span className="sr-only">{text}</span>

      <span aria-hidden="true">
        {groups.map((group, g) => (
          <Fragment key={group.key}>
            {g > 0 ? " " : null}
            <span className="inline-block whitespace-nowrap align-baseline">
              {group.units.map((unit) => (
                <motion.span
                  key={unit.key}
                  className="inline-block align-baseline"
                  initial={reduced ? false : HIDDEN}
                  animate={started ? SHOWN : HIDDEN}
                  transition={
                    reduced
                      ? { duration: 0 }
                      : {
                          duration: DURATION,
                          ease: EASE,
                          delay: started ? unit.index * step : 0,
                        }
                  }
                >
                  {unit.text}
                </motion.span>
              ))}
            </span>
          </Fragment>
        ))}
      </span>
    </span>
  );
}

Props

text
string

The sentence to reveal. It is rendered in full for assistive technology and split for display, so it is never truncated or reordered.

by"word"
"word" | "character"

Unit of the stagger. Characters stay grouped inside their word, which cannot break across a line.

stagger0.055
number

Seconds between two units, before clamping. This is the reading cadence, not a decoration.

maxDuration1.6
number

Ceiling in seconds for the whole reveal. Long text tightens its stagger to fit instead of running for a minute.

startOnViewtrue
boolean

Wait until the element is on screen. Set false when a click or a response, not scrolling, is the trigger.

playtrue
boolean

External gate. The reveal runs once this and the viewport condition are both satisfied.

oncetrue
boolean

Keep the text revealed after the first pass instead of re-hiding it when it scrolls away.

amount0.35
number

Fraction of the element that must be visible before the viewport trigger fires.

className""
string

Appended last. Set display, size and color here; the root is an inline span until you say otherwise.