Browse the docs
TextNo. 65 of 109
Blur Text
Text that comes into focus one character at a time, and can blur back out.
Installation
pnpm dlx shadcn@latest add @corsair-ui/blur-textNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsuse-in-viewuse-entrance
- npm
clsxtailwind-merge
Examples
On load
Plays on first paint from CSS alone, so it suits a heading above the fold.
Land ho, off the starboard bow.
Out and back
show={false} blurs it out, last character first.
Signal flags up, then down.
API
| Prop | Type | Default |
|---|---|---|
childrenPlain text; every character comes in. | string | — |
asThe element. | "p" | "h1"…"h6" | "span" | "div" | "p" |
triggerOn first paint, from CSS alone, or once in view. | "load" | "in-view" | "load" |
playManual control: false holds, true plays. | boolean | — |
showfalse blurs it back out, last character first. | boolean | true |
delay / stagger / durationIn ms. | number | 0 / 20 / 600 |
onAnimationCompleteAfter the last character. | () => void | — |
Accessibility
- Screen readers get the sentence in one piece; the animated characters are hidden from them.
- Words never break between characters.
- Hidden with show={false}, it is hidden from screen readers too. With prefers-reduced-motion it holds still and shows the finished state.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
blur-text.tsxView on GitHub"use client";
import {
Fragment,
useMemo,
type AnimationEvent,
type ComponentProps,
type CSSProperties,
type Ref,
} from "react";
import { useEntrance, type EntranceTrigger } from "@/registry/default/hooks/use-entrance";
import { cn } from "@/registry/default/lib/utils";
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
/** Splits into user-perceived characters, so emoji and accents stay whole. */
function graphemes(text: string) {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return Array.from(segmenter.segment(text), ({ segment }) => segment);
}
return Array.from(text);
}
type BlurTextTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
interface BlurTextProps extends Omit<ComponentProps<"p">, "children" | "onAnimationEnd"> {
/** Plain text; every character comes in on its own. */
children: string;
as?: BlurTextTag;
/** "load" plays on first paint with CSS alone; "in-view" when the text scrolls into view. */
trigger?: EntranceTrigger;
/** In-view: play the first time only. */
once?: boolean;
/** Takes over from `trigger`: `false` holds the text hidden, `true` plays it. */
play?: boolean;
/** `false` blurs the text back out, last character first. */
show?: boolean;
/** Wait before the first character, in ms. */
delay?: number;
/** Time between characters, in ms. */
stagger?: number;
/** How long each character takes, in ms. */
duration?: number;
/** Called when the last character has come in, or gone out. */
onAnimationComplete?: () => void;
}
/**
* Text that comes into focus one character at a time: each one rises a
* little out of a blur. Screen readers get the sentence in one piece, and
* with `prefers-reduced-motion` it is shown as is. Words never break
* between characters.
*
* @example
* <BlurText as="h1" className="text-5xl">Land ho.</BlurText>
*/
function BlurText({
children,
as: Tag = "p",
trigger = "load",
once = true,
play,
show = true,
delay = 0,
stagger = 20,
duration = 600,
onAnimationComplete,
className,
ref,
...props
}: BlurTextProps) {
const [observe, phase] = useEntrance<HTMLElement>({ trigger, once, play });
const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);
const words = useMemo(() => {
let next = 0;
return children
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => graphemes(word).map((character) => ({ character, index: next++ })));
}, [children]);
const count = words.reduce((sum, word) => sum + word.length, 0);
const piece = ({ character, index }: { character: string; index: number }) => {
// Out goes in reverse, so the text folds away from where it ended.
const order = show ? index : count - 1 - index;
const style: CSSProperties =
phase === "static" && show
? {}
: {
animationDelay: `${(show ? delay : 0) + order * stagger}ms`,
animationDuration: `${show ? duration : duration * 0.7}ms`,
animationPlayState: phase === "armed" && show ? "paused" : undefined,
};
return (
<span
key={index}
data-slot="blur-text-character"
// The character that finishes last: the end coming in, the start going out.
data-last={index === (show ? count - 1 : 0) ? "" : undefined}
className={cn(
"inline-block",
!show && "motion-safe:animate-blur-text-out motion-reduce:opacity-0",
show && phase !== "static" && "motion-safe:animate-blur-text"
)}
style={style}
>
{character}
</span>
);
};
const handleAnimationEnd = (event: AnimationEvent<HTMLElement>) => {
const target = event.target as HTMLElement;
if (target.dataset.last !== undefined) onAnimationComplete?.();
};
return (
<Tag
ref={mergedRef as Ref<never>}
data-slot="blur-text"
data-state={show ? phase : "hidden"}
aria-hidden={show ? undefined : true}
className={className}
onAnimationEnd={handleAnimationEnd}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{words.map((word, wordIndex) => (
<Fragment key={wordIndex}>
{wordIndex > 0 ? " " : null}
<span className="inline-block whitespace-nowrap">{word.map(piece)}</span>
</Fragment>
))}
</span>
</Tag>
);
}
export { BlurText, type BlurTextProps };