---
title: Reveal
description: Text that animates in by character, word, or line.
group: Text
order: 25
new: true
---

Text enters with a quiet preset: fade, blur, slide, or scale. Split by character, word, line, or the whole string.

```tsx
import { Reveal } from "@/components/motion/reveal"

export function Example() {
  return (
    <Reveal animation="blur-up" by="character">
      Quiet motion, one piece at a time.
    </Reveal>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add reveal
```

## Usage

```tsx
import { Reveal } from "@/components/motion/reveal"
```

```tsx
<Reveal animation="blur-up" by="character">
  Quiet motion, one piece at a time.
</Reveal>
```

## API Reference

### Reveal

| Prop | Type | Default |
| --- | --- | --- |
| text | string | — |
| children | ReactNode | — |
| animation | fade, blur, blur-up, slide-up, slide-left, scale | blur-up |
| by | character, word, line, text | character |
| duration | number | 420 |
| stagger | number | 35 |
| delay | number | 0 |
| play | number | 0 |
| startOnView | boolean | true |
| once | boolean | true |
| as | ElementType | p |
| segmentClassName | string | — |
| className | string | — |

Also accepts the other native element attributes for `as`. `duration` and `stagger` are in milliseconds. Pass string `children` or `text`. Bump `play` to replay.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type CSSProperties,
  type ElementType,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export const REVEAL_ANIMATIONS = [
  "fade",
  "blur",
  "blur-up",
  "slide-up",
  "slide-left",
  "scale",
] as const;

export const REVEAL_BY = ["character", "word", "line", "text"] as const;

export type RevealAnimation = (typeof REVEAL_ANIMATIONS)[number];
export type RevealBy = (typeof REVEAL_BY)[number];

type RevealOwnProps = {
  /** Text to animate. Falls back to string children. */
  text?: string;
  children?: ReactNode;
  /** Entrance preset. */
  animation?: RevealAnimation;
  /** How to split the text. */
  by?: RevealBy;
  /** Segment animation length in ms. */
  duration?: number;
  /** Delay between segments in ms. */
  stagger?: number;
  /** Delay before the first segment in ms. */
  delay?: number;
  /** Bump to replay. */
  play?: number;
  /** Start when the block enters view. */
  startOnView?: boolean;
  /** Only animate the first time it enters view. */
  once?: boolean;
  /** Element to render. */
  as?: ElementType;
  /** Class on each segment. */
  segmentClassName?: string;
};

export type RevealProps = RevealOwnProps &
  Omit<ComponentPropsWithoutRef<"p">, keyof RevealOwnProps | "children"> & {
    children?: ReactNode;
  };

function splitText(text: string, by: RevealBy) {
  if (by === "text") return [text];
  if (by === "line") return text.split("\n");
  if (by === "word") {
    return text.split(/(\s+)/).filter((part) => part.length > 0);
  }
  return Array.from(text);
}

function Reveal({
  className,
  text,
  children,
  animation = "blur-up",
  by = "character",
  duration = 420,
  stagger = 35,
  delay = 0,
  play = 0,
  startOnView = true,
  once = true,
  as: Comp = "p",
  segmentClassName,
  style,
  ...props
}: RevealProps) {
  const content =
    text ??
    (typeof children === "string" || typeof children === "number"
      ? String(children)
      : "");
  const segments = splitText(content, by);
  const ref = useRef<HTMLElement | null>(null);
  const [active, setActive] = useState(!startOnView);
  const [run, setRun] = useState(0);
  const seenRef = useRef(false);

  useEffect(() => {
    if (!startOnView) {
      setActive(true);
      return;
    }

    const node = ref.current;
    if (!node) return;

    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      setActive(true);
      return;
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) {
          if (!once) setActive(false);
          return;
        }
        setActive(true);
        seenRef.current = true;
        if (once) observer.disconnect();
      },
      { threshold: 0.25 },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [startOnView, once, play]);

  useEffect(() => {
    if (play === 0) return;
    setActive(false);
    const id = window.requestAnimationFrame(() => {
      setActive(true);
      setRun((n) => n + 1);
    });
    return () => window.cancelAnimationFrame(id);
  }, [play]);

  const ms = Math.max(duration, 80);
  const gap = Math.max(stagger, 0);
  const startDelay = Math.max(delay, 0);

  return (
    <Comp
      ref={ref as never}
      data-slot="reveal"
      data-animation={animation}
      data-by={by}
      data-active={active ? "true" : "false"}
      className={cn("arctis-reveal", className)}
      style={
        {
          "--reveal-duration": `${ms}ms`,
          ...style,
        } as CSSProperties
      }
      aria-label={content}
      {...props}
    >
      {segments.map((segment, index) => {
        const isSpace = /^\s+$/.test(segment);
        if (isSpace) {
          return (
            <span key={`space-${index}`} className="arctis-reveal-space">
              {segment}
            </span>
          );
        }

        return (
          <span
            key={`${run}-${index}-${segment.slice(0, 12)}`}
            className={cn("arctis-reveal-segment", segmentClassName)}
            style={
              {
                "--reveal-delay": `${startDelay + index * gap}ms`,
              } as CSSProperties
            }
          >
            {segment || "\u00A0"}
          </span>
        );
      })}
    </Comp>
  );
}

export { Reveal };
```
