Text Animationsv0.1.0
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.jsonManual
Install the dependencies
npm install motionAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
text* | string | – | Text to reveal. |
as | BlurRevealTag | "p" | Element to render. |
by | "word" | "character" | "word" | Reveal word by word or letter by letter. |
stagger | number | 0.045 words, 0.018 letters | Seconds between words (or letters). Long text is sped up so the whole reveal stays under ~1.6s. |
delay | number | 0 | Seconds before the first word starts. |
blur | number | 10 | Starting blur in px. |
offset | number | 8 | Starting vertical offset in px. |
trigger | "inView" | "mount" | "inView" | Reveal once scrolled into view, or straight away. |
once | boolean | true | Reveal only the first time it enters the viewport. |
amount | number | 0.4 | Fraction 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.