effects
Blur In Text
Words blur and fade into legibility one-by-one as the container scrolls into view, a 'tuning into focus' reveal. Each word animates on its own baseline (no translateY lift) so the block stays gravitationally anchored, and the initial blur radius scales with element type so a display heading blooms wider than body copy. Respects reduced-motion with a plain opacity crossfade and keeps a screen-reader-friendly full-text node.
Tuning the words into focus, one at a time.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/blur-in-textUsage
Usagetsx
<BlurInText>
{/* children */}
</BlurInText>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | Text content to animate. String children split into words; non-string children animate as a single block. |
| as | enum | p | Underlying HTML element. Drives semantic meaning + screen-reader output. Defaults to "p". |
| delay | number | 0 | Initial delay in seconds before the stagger begins. Defaults to 0. |
| stagger | number | 0.04 | Per-word stagger interval in seconds. Defaults to 0.04. |
| duration | number | 0.55 | Duration of each word's blur-to-focus transition in seconds. Defaults to 0.55. |
| blur | number | — | Starting blur radius in px. Overrides the per-element default. Set higher for display text. |
| once | boolean | true | Play only the first time the element enters the viewport. Defaults to true. |
| threshold | number | 0.15 | IntersectionObserver threshold (0–1) for triggering in-view. Defaults to 0.15. |
| className | string | Additional classes applied to the wrapper element. |
Source
blur-in-text.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "motion/react";
import styles from "./blur-in-text.module.css";
type AsElement = "p" | "h1" | "h2" | "h3" | "h4" | "span" | "div";
export type BlurInTextProps = {
/** Text content to animate. String children split into words; non-string children animate as a single block. */
children: React.ReactNode;
/** Underlying HTML element. Drives semantic meaning + screen-reader output. Defaults to "p". */
as?: AsElement;
/** Initial delay in seconds before the stagger begins. Defaults to 0. */
delay?: number;
/** Per-word stagger interval in seconds. Defaults to 0.04. */
stagger?: number;
/** Duration of each word's blur-to-focus transition in seconds. Defaults to 0.55. */
duration?: number;
/** Starting blur radius in px. Overrides the per-element default. Set higher for display text. */
blur?: number;
/** Play only the first time the element enters the viewport. Defaults to true. */
once?: boolean;
/** IntersectionObserver threshold (0–1) for triggering in-view. Defaults to 0.15. */
threshold?: number;
/** Additional classes applied to the wrapper element. */
className?: string;
};
// Blur scales with font weight of the element so display text blooms wider
// than body copy without the caller setting `blur` manually.
const BLUR_BY_ELEMENT: Record<AsElement, number> = {
h1: 14,
h2: 14,
h3: 10,
h4: 10,
p: 8,
span: 8,
div: 8,
};
// Prose guard: cap effective stagger so a long sentence does not drag past
// a comfortable total spread.
const PROSE_STAGGER_CAP = 0.025;
const PROSE_WORD_THRESHOLD = 8;
export function BlurInText({
children,
as = "p",
delay = 0,
stagger = 0.04,
duration = 0.55,
blur,
once = true,
threshold = 0.15,
className = "",
}: BlurInTextProps) {
const Tag = as;
// Mount + reduced-motion gating (contract snippet) — server and first client
// render are byte-identical (static) to avoid React #418 / flashes.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: threshold });
const blurPx = blur ?? BLUR_BY_ELEMENT[as];
// Split string children into words; preserve whitespace between them.
const isStringChild = typeof children === "string";
const words = isStringChild ? (children as string).split(/(\s+)/) : null;
// Effective per-word stagger: tighten for prose so long sentences stay snappy.
const wordCount = words ? words.filter((w) => w.trim().length > 0).length : 1;
const effectiveStagger =
wordCount > PROSE_WORD_THRESHOLD ? Math.min(stagger, PROSE_STAGGER_CAP) : stagger;
// Static base (server + pre-mount + reduced-motion): fully legible, no animation.
const animate = mounted && !reduce;
// ----- Static base (server + pre-mount + reduced-motion) or non-string children -----
// Gate the STRUCTURE on `animate` (mount + reduce), not `reduce` alone: on the
// server useReducedMotion() is null and on the client's first render it's a
// concrete boolean, so branching on `reduce` would make server HTML and the
// first client render diverge -> React #418. With `!animate`, both render this
// static block; the per-word structure only mounts post-hydration.
if (!isStringChild || !animate) {
return (
<Tag
ref={ref as React.Ref<never>}
aria-live="off"
className={[styles.wrapper, className].filter(Boolean).join(" ")}
>
<span
className={mounted && reduce && inView ? styles.reduced : undefined}
style={
mounted && reduce
? ({
// --duration token drives the reduced-motion fallback duration.
"--reduced-duration": "var(--duration, 300ms)",
} as React.CSSProperties)
: undefined
}
>
{children}
</span>
</Tag>
);
}
// ----- Full per-word blur-in (animated, non-reduced) -----
let wordIndex = 0;
return (
<Tag
ref={ref as React.Ref<never>}
aria-live="off"
className={[styles.wrapper, className].filter(Boolean).join(" ")}
>
{/* Visually hidden full string so assistive tech reads it uninterrupted. */}
<span className={styles.srOnly}>{children}</span>
<span
aria-hidden="true"
className={animate && inView ? styles.animate : undefined}
>
{words!.map((token, i) => {
if (token.trim().length === 0) {
// Whitespace token — render as-is, no animation, no index cost.
return <span key={i}>{token}</span>;
}
const thisDelay = delay + wordIndex * effectiveStagger;
wordIndex += 1;
return (
<span
key={i}
className={styles.word}
style={
{
"--blur-px": `${blurPx}px`,
"--word-duration": `${duration}s`,
"--word-delay": `${thisDelay}s`,
} as React.CSSProperties
}
>
{token}
</span>
);
})}
</span>
</Tag>
);
}
Dependencies
- motion
- @bottega/tokens