Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/blur-text.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
blur-text.tsx. It's yours: change anything.
Usage
import { BlurText } from "@/components/blur-text"
export function Hero() {
return (
<BlurText
as="h1"
trigger="mount"
text="Interfaces that feel considered, from the very first frame."
highlight={["considered"]}
className="text-4xl font-semibold tracking-tight"
/>
)
}Source
One file, 151 lines, nothing hidden. Read it before you install it.
"use client";
import { motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface BlurTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
text: string;
/** Element to render, so headings stay headings. */
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
/** Reveal word by word or letter by letter. */
by?: "word" | "letter";
/** Words to bring out once the text has resolved: they take the accent colour and an underline draws beneath them. */
highlight?: string[];
/** `view`: when it scrolls into view. `mount`: straight away (for text already on screen). */
trigger?: "view" | "mount";
/** Seconds before the first piece starts. */
delay?: number;
/** Seconds between pieces. */
stagger?: number;
/** Only reveal the first time it scrolls into view. */
once?: boolean;
}
// Motion presets (ADR-010).
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
(notify) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
},
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clean = (word: string) => word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
/**
* Text that comes into focus piece by piece: each word (or letter) rises
* out of a blur, and highlighted words then take the accent colour while a
* line draws beneath them.
*
* Screen readers get the whole sentence once, as plain text; the animated
* pieces are hidden from them. Under `prefers-reduced-motion` it is just
* text, with highlighted words already in colour.
* Colours: `--blur-text-accent` (default `--primary`).
*/
export function BlurText({
text,
as = "p",
by = "word",
highlight,
trigger = "view",
delay = 0,
stagger,
once = true,
className,
...props
}: BlurTextProps) {
const reduceMotion = usePrefersReducedMotion();
const Tag = as;
const marked = new Set((highlight ?? []).map(clean));
const isMarked = (word: string) => marked.has(clean(word));
if (reduceMotion) {
return (
<Tag className={cn("[--blur-text-accent-current:var(--blur-text-accent,var(--primary))]", className)} {...props}>
{marked.size
? text.split(/(\s+)/).map((piece, i) =>
isMarked(piece) ? (
<span key={i} className="text-[var(--blur-text-accent-current)]">
{piece}
</span>
) : (
piece
),
)
: text}
</Tag>
);
}
// Keep spaces as their own pieces so words wrap naturally.
const words = text.split(/(\s+)/);
const step = stagger ?? (by === "word" ? 0.08 : 0.025);
const pieceCount = by === "word" ? words.filter((w) => !/^\s+$/.test(w)).length : Array.from(text).length;
// Highlights start once the last piece has nearly resolved.
const settled = delay + pieceCount * step + 0.35;
const piece = {
hidden: { opacity: 0, filter: "blur(10px)", y: "0.25em" },
shown: { opacity: 1, filter: "blur(0px)", y: 0, transition: { duration: 0.6, ease: EASE_OUT } },
};
const reveal = trigger === "mount" ? { animate: "shown" } : { whileInView: "shown", viewport: { once, amount: 0.4 } };
return (
<Tag className={cn("[overflow-wrap:anywhere] [--blur-text-accent-current:var(--blur-text-accent,var(--primary))]", className)} {...props}>
<span className="sr-only">{text}</span>
<motion.span aria-hidden="true" initial="hidden" {...reveal} transition={{ delayChildren: delay, staggerChildren: step }}>
{words.map((word, w) => {
if (/^\s+$/.test(word)) return <React.Fragment key={w}>{word}</React.Fragment>;
const letters = (text: string) =>
Array.from(text).map((char, c) => (
<motion.span key={c} className="inline-block whitespace-pre will-change-[filter,transform,opacity]" variants={piece}>
{char}
</motion.span>
));
const plain = (text: string) =>
by === "word" ? (
<motion.span className="inline-block whitespace-pre will-change-[filter,transform,opacity]" variants={piece}>
{text}
</motion.span>
) : (
<span className="inline-block whitespace-pre">{letters(text)}</span>
);
if (!isMarked(word)) return <React.Fragment key={w}>{plain(word)}</React.Fragment>;
// Only the word itself is brought out, not the punctuation around it.
const [, lead = "", core = word, trail = ""] = /^([^\p{L}\p{N}]*)(.*?)([^\p{L}\p{N}]*)$/u.exec(word) ?? [];
return (
<span key={w} className="inline-block whitespace-pre">
{lead && plain(lead)}
<span className="relative inline-block">
{plain(core)}
{/* The same word in the accent colour fades in over it once the text has resolved. */}
<motion.span
className="absolute inset-0 text-[var(--blur-text-accent-current)]"
variants={{ hidden: { opacity: 0 }, shown: { opacity: 1, transition: { delay: settled, duration: 0.5, ease: EASE_OUT } } }}
>
{core}
</motion.span>
<motion.span
className="absolute inset-x-0 -bottom-[0.06em] h-[0.08em] min-h-0.5 origin-left rounded-full bg-[var(--blur-text-accent-current)]"
variants={{
hidden: { scaleX: 0 },
shown: { scaleX: 1, transition: { delay: settled + 0.1, duration: 0.6, ease: EASE_IN_OUT } },
}}
/>
</span>
{trail && plain(trail)}
</span>
);
})}
</motion.span>
</Tag>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text to reveal. |
| as | "h1" | "h2" | "h3" | "h4" | "p" | "span" | "p" | Element to render, so headings stay headings. |
| by | "word" | "letter" | "word" | Size of each animated piece. |
| highlight | string[] | — | Words to bring out after the reveal: accent colour and a drawn underline. |
| trigger | "view" | "mount" | "view" | Reveal when scrolled into view, or straight away. |
| delay | number | 0 | Seconds before the first piece. |
| stagger | number | 0.08 (word) / 0.025 (letter) | Seconds between pieces. |
| once | boolean | true | Reveal only the first time it scrolls into view. |
| --blur-text-accent | CSS variable | --primary | Colour of highlighted words and their underline. |
Accessibility
- Screen readers hear the whole text once, as plain text; the animated pieces are aria-hidden.
- Renders the element you choose, so your heading outline is unchanged.
- Plain, unanimated text under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.