DesignPass.dev

BlurText

// preview

Text
Split by
charswords
Drift from
topbottomleftright
Blur (px)
Drift distance (px)
Stagger (ms)
Duration (ms)

// source

TSJS
TailwindCSS
/*!
 * BlurText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
 * Docs & live playground: https://designpass.dev/components/blur-text
 * MIT licensed, keep this notice in copies and adaptations.
 */
"use client";

import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";

export interface BlurTextProps {
  text: string;
  /** Animate per word or per character. */
  splitBy?: "words" | "chars";
  /** Delay (ms) before the reveal starts once in view. */
  delay?: number;
  /** Gap (ms) between consecutive units. */
  stagger?: number;
  /** Duration (ms) of each unit's focus-in. */
  duration?: number;
  /** Which side each unit drifts in from. */
  direction?: "top" | "bottom" | "left" | "right";
  /** Starting blur radius (px). */
  blur?: number;
  /** Drift distance (px). */
  distance?: number;
  /** IntersectionObserver threshold that triggers the reveal. */
  threshold?: number;
  rootMargin?: string;
  /** Play only the first time it enters the viewport. */
  once?: boolean;
  onComplete?: () => void;
  className?: string;
  style?: CSSProperties;
}

type BlurDirection = NonNullable<BlurTextProps["direction"]>;

/** Entry or overshoot transform for a drift direction. */
function blurDrift(direction: BlurDirection, distance: number, overshoot = false) {
  const sign = direction === "top" || direction === "left" ? -1 : 1;
  const amount = distance * (overshoot ? -0.18 : 1) * sign;
  if (direction === "top" || direction === "bottom") {
    return `translate3d(0, ${amount}px, 0)`;
  }
  return `translate3d(${amount}px, 0, 0)`;
}

/**
 * Text that drifts in out-of-focus and sharpens into place, unit by unit.
 * A midpoint keyframe (half blur, slight overshoot) makes the focus pull
 * feel optical instead of linear. Web Animations API, zero dependencies,
 * SEO-safe markup, honors prefers-reduced-motion.
 */
export default function BlurText({
  text,
  splitBy = "words",
  delay = 0,
  stagger = 90,
  duration = 750,
  direction = "top",
  blur = 10,
  distance = 24,
  threshold = 0.15,
  rootMargin = "0px",
  once = true,
  onComplete,
  className = "",
  style,
}: BlurTextProps) {
  const containerRef = useRef<HTMLSpanElement>(null);
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const elements = Array.from(container.querySelectorAll<HTMLElement>("[data-blur-unit]"));
    if (elements.length === 0) return;

    // Paused animations with backwards fill keep the SSR text visible until
    // JS takes over, then hide the units until the reveal plays.
    const animations = elements.map((unit, i) => {
      const animation = unit.animate(
        [
          {
            opacity: 0,
            filter: `blur(${blur}px)`,
            transform: blurDrift(direction, distance),
          },
          {
            opacity: 0.7,
            filter: `blur(${blur / 2}px)`,
            transform: blurDrift(direction, distance, true),
            offset: 0.55,
          },
          { opacity: 1, filter: "blur(0px)", transform: "translate3d(0, 0, 0)" },
        ],
        {
          duration,
          delay: delay + i * stagger,
          easing: "cubic-bezier(0.25, 0.75, 0.35, 1)",
          fill: "both",
        },
      );
      animation.pause();
      return animation;
    });

    let played = false;
    const play = () => {
      for (const animation of animations) {
        animation.currentTime = 0;
        animation.play();
      }
      if (!played) {
        played = true;
        Promise.all(animations.map((animation) => animation.finished))
          .then(() => onCompleteRef.current?.())
          .catch(() => {});
      }
    };

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          play();
          if (once) observer.disconnect();
        }
      },
      { threshold, rootMargin },
    );
    observer.observe(container);

    return () => {
      observer.disconnect();
      for (const animation of animations) animation.cancel();
    };
  }, [text, splitBy, delay, stagger, duration, direction, blur, distance, threshold, rootMargin, once]);

  return (
    <span
      ref={containerRef}
      aria-label={text}
      role="text"
      className={`inline-block ${className}`}
      style={style}
    >
      {words.map((word, wordIndex) => (
        <React.Fragment key={`${word}-${wordIndex}`}>
          {splitBy === "words" ? (
            <span
              data-blur-unit
              aria-hidden="true"
              className="inline-block will-change-[transform,filter]"
            >
              {word}
            </span>
          ) : (
            // Chars are grouped per word in a nowrap box so lines still
            // break between words, never through them.
            <span aria-hidden="true" className="inline-block whitespace-nowrap">
              {[...word].map((char, charIndex) => (
                <span
                  key={charIndex}
                  data-blur-unit
                  className="inline-block will-change-[transform,filter]"
                >
                  {char}
                </span>
              ))}
            </span>
          )}
          {wordIndex < words.length - 1 ? " " : null}
        </React.Fragment>
      ))}
    </span>
  );
}

// install

Install the BlurText component from DesignPass into this project by running:

npx shadcn@latest add "https://designpass.dev/r/BlurText-TS-TW.json"

If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.

Need the license details? Read the component license.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.