Skip to content
Uxelith
Sign in
Freev1.0.0Text & Motion

Blur Text

Text that comes into focus word by word or letter by letter, then brings out the words you choose: they take the accent colour while a line draws beneath them. Screen readers get the sentence once.

pnpm dlx shadcn add …/r/blur-text.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/blur-text.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as blur-text.tsx. It's yours: change anything.

Usage

example.tsx
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.

blur-text.tsx
"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

PropTypeDefaultDescription
textstring—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.
highlightstring[]—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.
delaynumber0Seconds before the first piece.
staggernumber0.08 (word) / 0.025 (letter)Seconds between pieces.
oncebooleantrueReveal only the first time it scrolls into view.
--blur-text-accentCSS variable--primaryColour 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

  1. v1.0.0Latest

    First release.

More in Text & Motion

All 4 in Text & Motion
  • Text & MotionFree

    Marquee

    Content that loops past across or up and down with no gaps, at a steady speed, with soft faded edges. Pauses on hover and keyboard focus.

  • Text & MotionFree

    Shimmer Text

    Text with a band of light that sweeps across it, sized to the text so short labels and long headings move at the same pace. Settles to plain text when the work is done. Pure CSS, renders on the server.

  • Text & MotionFree

    Typewriter

    Text that types itself out with a human rhythm, a caret that holds steady while typing and blinks when idle, and words that are backspaced or selected and replaced. Starts when scrolled into view; screen readers get the sentence once.