Text Effects

Blur Reveal Heading

A heading that reveals per word — blur(8px) and 8px rise with a 60ms stagger — once, on first view, with an as prop for h1 through h3.

Install

npx shadcn@latest add @paragon/blur-reveal-heading

blur-reveal-heading.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface BlurRevealHeadingProps
  extends Omit<React.ComponentProps<"h2">, "children"> {
  /** The heading text. */
  children: string;
  /** Heading level to render. */
  as?: "h1" | "h2" | "h3";
  /** Milliseconds before the first word enters. */
  delay?: number;
  /** Milliseconds between words. */
  stagger?: number;
  /** Render the heading plainly with no motion. */
  static?: boolean;
}

/**
 * A heading that reveals per word — blur(8px) + translateY(8px) → 0 with a
 * 60ms stagger — the first time it scrolls into view, once.
 *
 * The full string stays available to screen readers; the animated words are
 * decorative. text-balance is on by default (house base styles balance
 * h1–h3). Reduced motion collapses the reveal to a plain staggerless fade.
 */
export function BlurRevealHeading({
  children,
  as: Tag = "h2",
  delay = 0,
  stagger = 60,
  static: isStatic = false,
  className,
  ...props
}: BlurRevealHeadingProps) {
  const ref = React.useRef<HTMLHeadingElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.5 });
  const reducedMotion = useReducedMotion() ?? false;

  const words = React.useMemo(
    () => children.split(/\s+/).filter(Boolean),
    [children],
  );

  if (isStatic) {
    return (
      <Tag className={cn("text-balance", className)} {...props}>
        {children}
      </Tag>
    );
  }

  return (
    <Tag ref={ref} className={cn("text-balance", className)} {...props}>
      <span className="sr-only">{children}</span>
      <span aria-hidden="true">
        {words.map((word, i) => (
          <React.Fragment key={i}>
            <span
              className="inline-block"
              style={{
                opacity: inView ? 1 : 0,
                transform:
                  reducedMotion || inView
                    ? "translateY(0px)"
                    : "translateY(8px)",
                filter: reducedMotion || inView ? "blur(0px)" : "blur(8px)",
                transitionProperty: reducedMotion
                  ? "opacity"
                  : "opacity, transform, filter",
                // Page-level reveal: the one place the 300ms UI ceiling lifts.
                transitionDuration: "500ms",
                transitionTimingFunction: "var(--ease-out)",
                transitionDelay: inView
                  ? `${delay + (reducedMotion ? 0 : i * stagger)}ms`
                  : "0ms",
              }}
            >
              {word}
            </span>
            {i < words.length - 1 ? " " : null}
          </React.Fragment>
        ))}
      </span>
    </Tag>
  );
}