A heading that reveals per word — blur(8px) and an em-relative 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;
/** Milliseconds each word takes to settle. */
duration?: number;
/** Render the heading plainly with no motion. */
static?: boolean;
}
/**
* A heading that reveals per word — blur(8px) + a 0.35em rise → 0 with a
* 60ms stagger — the first time it scrolls into view, once.
*
* The rise is em-relative so the motion scales with any font size. Words
* animate in place inside normal inline flow (no absolute positioning), so
* the heading occupies its final space from the first frame — zero layout
* shift. 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,
duration = 500,
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={`${word}-${i}`}>
<span
className="inline-block"
style={{
opacity: inView ? 1 : 0,
// em-relative rise so the motion reads the same at any size.
transform:
reducedMotion || inView
? "translateY(0)"
: "translateY(0.35em)",
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: `${duration}ms`,
transitionTimingFunction: "var(--ease-out)",
transitionDelay: inView
? `${delay + (reducedMotion ? 0 : i * stagger)}ms`
: "0ms",
}}
>
{word}
</span>
{i < words.length - 1 ? " " : null}
</React.Fragment>
))}
</span>
</Tag>
);
}