Blur Text
Text starts blurred then crisply resolves for a soft-focus reveal effect.
pnpm dlx shadcn@latest add @leo/blur-textProps
| Name | Type | Default | Description |
|---|---|---|---|
| animateBy | undefined | "words" | "letters" | words | |
| animationFrom | Record<string, string | number> | undefined | - | |
| animationTo | Record<string, string | number>[] | undefined | - | |
| className | string | undefined | ||
| delay | number | undefined | 200 | |
| direction | undefined | "top" | "bottom" | top | |
| easing | Easing | Easing[] | undefined | (t: number) => t | |
| onAnimationComplete | (() => void) | undefined | - | |
| rootMargin | string | undefined | 0px | |
| stepDuration | number | undefined | 0.35 | |
| text | string | undefined | ||
| threshold | number | undefined | 0.1 |
Dependencies
- motion@^12.23.12
Source
"use client";
/*
* Ported from react-bits (MIT, © David Haz): src/ts-tailwind/TextAnimations/BlurText/BlurText.tsx @ 9d0270d
*
* MIT License
*
* Copyright (c) 2026 David Haz
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { type Easing, motion, type Transition } from "motion/react";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface BlurTextProps {
text?: string;
delay?: number;
className?: string;
animateBy?: "words" | "letters";
direction?: "top" | "bottom";
threshold?: number;
rootMargin?: string;
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: Easing | Easing[];
onAnimationComplete?: () => void;
stepDuration?: number;
}
const buildKeyframes = (
from: Record<string, string | number>,
steps: Array<Record<string, string | number>>,
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap((s) => Object.keys(s))]);
const keyframes: Record<string, Array<string | number>> = {};
keys.forEach((k) => {
keyframes[k] = [from[k], ...steps.map((s) => s[k])];
});
return keyframes;
};
const BlurText: React.FC<BlurTextProps> = ({
text = "",
delay = 200,
className = "",
animateBy = "words",
direction = "top",
threshold = 0.1,
rootMargin = "0px",
animationFrom,
animationTo,
easing = (t: number) => t,
onAnimationComplete,
stepDuration = 0.35,
}) => {
const elements = animateBy === "words" ? text.split(" ") : text.split("");
const [inView, setInView] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.unobserve(ref.current as Element);
}
},
{ threshold, rootMargin },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [threshold, rootMargin]);
const defaultFrom = useMemo(
() =>
direction === "top"
? { filter: "blur(10px)", opacity: 0, y: -50 }
: { filter: "blur(10px)", opacity: 0, y: 50 },
[direction],
);
const defaultTo = useMemo(
() => [
{
filter: "blur(5px)",
opacity: 0.5,
y: direction === "top" ? 5 : -5,
},
{ filter: "blur(0px)", opacity: 1, y: 0 },
],
[direction],
);
const fromSnapshot = animationFrom ?? defaultFrom;
const toSnapshots = animationTo ?? defaultTo;
const stepCount = toSnapshots.length + 1;
const totalDuration = stepDuration * (stepCount - 1);
const times = Array.from({ length: stepCount }, (_, i) =>
stepCount === 1 ? 0 : i / (stepCount - 1),
);
return (
<p ref={ref} className={cn("blur-text flex flex-wrap", className)}>
{elements.map((segment, index) => {
const animateKeyframes = buildKeyframes(fromSnapshot, toSnapshots);
const spanTransition: Transition = {
duration: totalDuration,
times,
delay: (index * delay) / 1000,
ease: easing,
};
return (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reordered
key={index}
initial={fromSnapshot}
animate={inView ? animateKeyframes : fromSnapshot}
transition={spanTransition}
onAnimationComplete={index === elements.length - 1 ? onAnimationComplete : undefined}
style={{
display: "inline-block",
willChange: "transform, filter, opacity",
}}
>
{segment === " " ? "\u00A0" : segment}
{animateBy === "words" && index < elements.length - 1 && "\u00A0"}
</motion.span>
);
})}
</p>
);
};
export { BlurText };
export default BlurText;