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.
npx shadcn@latest add @paragon/blur-reveal-heading"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>
);
}