bottega

effects

Blur In Text

Words blur and fade into legibility one-by-one as the container scrolls into view, a 'tuning into focus' reveal. Each word animates on its own baseline (no translateY lift) so the block stays gravitationally anchored, and the initial blur radius scales with element type so a display heading blooms wider than body copy. Respects reduced-motion with a plain opacity crossfade and keeps a screen-reader-friendly full-text node.

Tuning the words into focus, one at a time.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/blur-in-text

Usage

Usagetsx
<BlurInText>
  {/* children */}
</BlurInText>

Props

PropTypeDefaultDescription
children*React.ReactNodeText content to animate. String children split into words; non-string children animate as a single block.
asenumpUnderlying HTML element. Drives semantic meaning + screen-reader output. Defaults to "p".
delaynumber0Initial delay in seconds before the stagger begins. Defaults to 0.
staggernumber0.04Per-word stagger interval in seconds. Defaults to 0.04.
durationnumber0.55Duration of each word's blur-to-focus transition in seconds. Defaults to 0.55.
blurnumberStarting blur radius in px. Overrides the per-element default. Set higher for display text.
oncebooleantruePlay only the first time the element enters the viewport. Defaults to true.
thresholdnumber0.15IntersectionObserver threshold (0–1) for triggering in-view. Defaults to 0.15.
classNamestringAdditional classes applied to the wrapper element.

Source

blur-in-text.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "motion/react";
import styles from "./blur-in-text.module.css";

type AsElement = "p" | "h1" | "h2" | "h3" | "h4" | "span" | "div";

export type BlurInTextProps = {
  /** Text content to animate. String children split into words; non-string children animate as a single block. */
  children: React.ReactNode;
  /** Underlying HTML element. Drives semantic meaning + screen-reader output. Defaults to "p". */
  as?: AsElement;
  /** Initial delay in seconds before the stagger begins. Defaults to 0. */
  delay?: number;
  /** Per-word stagger interval in seconds. Defaults to 0.04. */
  stagger?: number;
  /** Duration of each word's blur-to-focus transition in seconds. Defaults to 0.55. */
  duration?: number;
  /** Starting blur radius in px. Overrides the per-element default. Set higher for display text. */
  blur?: number;
  /** Play only the first time the element enters the viewport. Defaults to true. */
  once?: boolean;
  /** IntersectionObserver threshold (0–1) for triggering in-view. Defaults to 0.15. */
  threshold?: number;
  /** Additional classes applied to the wrapper element. */
  className?: string;
};

// Blur scales with font weight of the element so display text blooms wider
// than body copy without the caller setting `blur` manually.
const BLUR_BY_ELEMENT: Record<AsElement, number> = {
  h1: 14,
  h2: 14,
  h3: 10,
  h4: 10,
  p: 8,
  span: 8,
  div: 8,
};

// Prose guard: cap effective stagger so a long sentence does not drag past
// a comfortable total spread.
const PROSE_STAGGER_CAP = 0.025;
const PROSE_WORD_THRESHOLD = 8;

export function BlurInText({
  children,
  as = "p",
  delay = 0,
  stagger = 0.04,
  duration = 0.55,
  blur,
  once = true,
  threshold = 0.15,
  className = "",
}: BlurInTextProps) {
  const Tag = as;

  // Mount + reduced-motion gating (contract snippet) — server and first client
  // render are byte-identical (static) to avoid React #418 / flashes.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced

  const ref = useRef<HTMLElement>(null);
  const inView = useInView(ref, { once, amount: threshold });

  const blurPx = blur ?? BLUR_BY_ELEMENT[as];

  // Split string children into words; preserve whitespace between them.
  const isStringChild = typeof children === "string";
  const words = isStringChild ? (children as string).split(/(\s+)/) : null;

  // Effective per-word stagger: tighten for prose so long sentences stay snappy.
  const wordCount = words ? words.filter((w) => w.trim().length > 0).length : 1;
  const effectiveStagger =
    wordCount > PROSE_WORD_THRESHOLD ? Math.min(stagger, PROSE_STAGGER_CAP) : stagger;

  // Static base (server + pre-mount + reduced-motion): fully legible, no animation.
  const animate = mounted && !reduce;

  // ----- Static base (server + pre-mount + reduced-motion) or non-string children -----
  // Gate the STRUCTURE on `animate` (mount + reduce), not `reduce` alone: on the
  // server useReducedMotion() is null and on the client's first render it's a
  // concrete boolean, so branching on `reduce` would make server HTML and the
  // first client render diverge -> React #418. With `!animate`, both render this
  // static block; the per-word structure only mounts post-hydration.
  if (!isStringChild || !animate) {
    return (
      <Tag
        ref={ref as React.Ref<never>}
        aria-live="off"
        className={[styles.wrapper, className].filter(Boolean).join(" ")}
      >
        <span
          className={mounted && reduce && inView ? styles.reduced : undefined}
          style={
            mounted && reduce
              ? ({
                  // --duration token drives the reduced-motion fallback duration.
                  "--reduced-duration": "var(--duration, 300ms)",
                } as React.CSSProperties)
              : undefined
          }
        >
          {children}
        </span>
      </Tag>
    );
  }

  // ----- Full per-word blur-in (animated, non-reduced) -----
  let wordIndex = 0;
  return (
    <Tag
      ref={ref as React.Ref<never>}
      aria-live="off"
      className={[styles.wrapper, className].filter(Boolean).join(" ")}
    >
      {/* Visually hidden full string so assistive tech reads it uninterrupted. */}
      <span className={styles.srOnly}>{children}</span>

      <span
        aria-hidden="true"
        className={animate && inView ? styles.animate : undefined}
      >
        {words!.map((token, i) => {
          if (token.trim().length === 0) {
            // Whitespace token — render as-is, no animation, no index cost.
            return <span key={i}>{token}</span>;
          }
          const thisDelay = delay + wordIndex * effectiveStagger;
          wordIndex += 1;
          return (
            <span
              key={i}
              className={styles.word}
              style={
                {
                  "--blur-px": `${blurPx}px`,
                  "--word-duration": `${duration}s`,
                  "--word-delay": `${thisDelay}s`,
                } as React.CSSProperties
              }
            >
              {token}
            </span>
          );
        })}
      </span>
    </Tag>
  );
}

Dependencies

  • motion
  • @bottega/tokens