Skip to content

Blur Reveal Text

Words reveal from blur as they scroll into view.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/blur-reveal-text.json

Manual

  1. Install the dependencies

    npm install motion
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/blur-reveal-text/blur-reveal-text.tsx
    "use client";
    
    import { useRef, type ComponentProps, type ElementType } from "react";
    import { motion, useInView, useReducedMotion, type Variants } from "motion/react";
    import { duration, ease } from "@/lib/motion";
    
    export type BlurRevealTag = "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "blockquote";
    
    export interface BlurRevealTextProps extends Omit<ComponentProps<"p">, "children"> {
      /** Text to reveal. */
      text: string;
      /** Element to render. @default "p" */
      as?: BlurRevealTag;
      /** Reveal word by word or letter by letter. @default "word" */
      by?: "word" | "character";
      /** Seconds between words (or letters). Long text is sped up so the whole reveal stays under ~1.6s. @default 0.045 words, 0.018 letters */
      stagger?: number;
      /** Seconds before the first word starts. @default 0 */
      delay?: number;
      /** Starting blur in px. @default 10 */
      blur?: number;
      /** Starting vertical offset in px. @default 8 */
      offset?: number;
      /** Reveal once scrolled into view, or straight away. @default "inView" */
      trigger?: "inView" | "mount";
      /** Reveal only the first time it enters the viewport. @default true */
      once?: boolean;
      /** Fraction of the text that must be visible to start. @default 0.4 */
      amount?: number;
    }
    
    type Piece = { kind: "space"; value: string } | { kind: "word"; chars: string[]; index: number };
    
    function graphemes(value: string) {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment);
      }
      return Array.from(value);
    }
    
    /** Words (or letters) with a running index for the stagger; whitespace stays plain text so lines wrap naturally. */
    function split(text: string, by: "word" | "character") {
      const pieces: Piece[] = [];
      let index = 0;
      for (const token of text.split(/(\s+)/)) {
        if (!token) continue;
        if (/^\s+$/.test(token)) {
          pieces.push({ kind: "space", value: token });
        } else {
          const chars = by === "word" ? [token] : graphemes(token);
          pieces.push({ kind: "word", chars, index });
          index += chars.length;
        }
      }
      return { pieces, count: index };
    }
    
    export function BlurRevealText({
      text,
      as = "p",
      by = "word",
      stagger,
      delay = 0,
      blur = 10,
      offset = 8,
      trigger = "inView",
      once = true,
      amount = 0.4,
      ...props
    }: BlurRevealTextProps) {
      const reduceMotion = useReducedMotion();
      const ref = useRef<HTMLSpanElement>(null);
      const inView = useInView(ref, { once, amount });
      const shown = trigger === "mount" || inView;
      const { pieces, count } = split(text, by);
      const step = Math.min(stagger ?? (by === "word" ? 0.045 : 0.018), 1.6 / Math.max(count, 1)) * (reduceMotion ? 0.5 : 1);
    
      const variants: Variants = {
        hidden: reduceMotion ? { opacity: 0 } : { opacity: 0, y: offset, filter: `blur(${blur}px)` },
        visible: (i: number) => ({
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { delay: delay + i * step, duration: reduceMotion ? duration.base : duration.slow + 0.15, ease: ease.out },
          // Drop the filter once settled so finished words cost nothing to paint.
          transitionEnd: { filter: "none" },
        }),
      };
      const state = shown ? "visible" : "hidden";
      const Tag = as as ElementType;
    
      return (
        <Tag {...props}>
          <span className="sr-only select-none">{text}</span>
          <span ref={ref} aria-hidden="true">
            {pieces.map((piece, p) =>
              piece.kind === "space" ? (
                piece.value
              ) : by === "word" ? (
                <motion.span key={p} custom={piece.index} variants={variants} initial="hidden" animate={state} className="inline-block">
                  {piece.chars[0]}
                </motion.span>
              ) : (
                <span key={p} className="inline-block whitespace-nowrap">
                  {piece.chars.map((char, c) => (
                    <motion.span key={c} custom={piece.index + c} variants={variants} initial="hidden" animate={state} className="inline-block">
                      {char}
                    </motion.span>
                  ))}
                </span>
              ),
            )}
          </span>
        </Tag>
      );
    }

Usage

import { BlurRevealText } from "@/components/ui/blur-reveal-text/blur-reveal-text";

export function Manifesto() {
  return (
    <BlurRevealText
      as="h2"
      className="text-3xl font-semibold"
      text="Good motion is invisible."
    />
  );
}

API reference

PropTypeDefaultDescription
text*string–Text to reveal.
asBlurRevealTag"p"Element to render.
by"word" | "character""word"Reveal word by word or letter by letter.
staggernumber0.045 words, 0.018 lettersSeconds between words (or letters). Long text is sped up so the whole reveal stays under ~1.6s.
delaynumber0Seconds before the first word starts.
blurnumber10Starting blur in px.
offsetnumber8Starting vertical offset in px.
trigger"inView" | "mount""inView"Reveal once scrolled into view, or straight away.
oncebooleantrueReveal only the first time it enters the viewport.
amountnumber0.4Fraction of the text that must be visible to start.

Also accepts every native <p> attribute, including ref and className.

  • Whitespace stays as plain text between words, so lines wrap exactly as they would without the effect.
  • The stagger is capped so the whole reveal finishes in about 1.6s, however long the text. Each word drops its filter once settled.
  • Uses IntersectionObserver, so it also works inside scroll containers.

Accessibility

  • Screen readers read the whole text from a visually hidden copy, not word by word, and the animated words are aria-hidden.
  • With reduced motion enabled, words fade in without blur or movement, twice as fast.