Swish UI
All componentsText Free

Split blur in

A heading appears word by word, each one rising out of a soft blur a beat after the last.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import { useInView } from "@/lib/motion";
import styles from "./demo.module.css";

const HEADING = "Slow mornings, sea views and a pool that is all yours.";

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const [started, setStarted] = useState(false);
  const [run, setRun] = useState(0);

  useEffect(() => {
    if (inView) setStarted(true);
  }, [inView]);

  return (
    <div ref={root} className={styles.root}>
      <div key={run} className={`${styles.copy} ${started ? styles.play : ""}`}>
        <p className={styles.eyebrow}>Villa Azul, Comporta</p>
        <h2 className={styles.title} aria-label={HEADING}>
          {HEADING.split(" ").map((word, i) => (
            <span key={i} aria-hidden="true">
              <span className={styles.word} style={{ "--i": i } as React.CSSProperties}>{word}</span>{" "}
            </span>
          ))}
        </h2>
      </div>
      <button type="button" className={styles.replay} onClick={() => setRun((r) => r + 1)}>
        Replay
      </button>
    </div>
  );
}

demo.module.css

.root { container-type: size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; text-align: center; }
.copy { display: grid; gap: 12px; justify-items: center; }
.eyebrow { font-size: 13px; font-weight: 600; color: var(--accent-text); }
.title { max-width: 15em; font-size: clamp(24px, min(6.8cqi, 11cqh), 54px); line-height: 1.12; letter-spacing: -0.03em; font-weight: 650; text-wrap: balance; }
.word { display: inline-block; }
.copy:not(.play) .word, .copy:not(.play) .eyebrow { opacity: 0; }
.play .eyebrow { animation: rise 0.8s var(--out) both; }
.play .word { animation: rise 0.9s var(--out) both; animation-delay: calc(0.15s + var(--i) * 70ms); }
@keyframes rise { from { opacity: 0; filter: blur(10px); translate: 0 0.35em; } }
.replay { min-height: 44px; padding: 0 18px; border-radius: 999px; border: 1px solid var(--line); background: var(--white); font-size: 14px; font-weight: 600; cursor: pointer; transition: border-color 0.18s; }
.replay:hover { border-color: var(--ink); }
.replay:active { scale: 0.97; }
@media (prefers-reduced-motion: reduce) {
  .copy:not(.play) .word, .copy:not(.play) .eyebrow { opacity: 1; }
  .play .word, .play .eyebrow { animation: none; }
}

Use it for

  • The first headline of a hero or a section intro
  • Short statements on editorial and brand pages

Avoid

  • Paragraphs and UI labels, the stagger delays reading
  • Headings that sit below the fold on every page, it gets tiresome on repeat

How it works

The heading is split into one inline-block span per word, each with an index in a --i custom property. One keyframe animates from opacity 0, blur(10px) and a 0.35em drop to the resting state, and animation-delay is 0.15s plus i times 70ms. The class that starts it is added when the demo enters the viewport, and Replay remounts the block with a new key so the animation runs again.

Accessibility

The heading carries the full sentence in aria-label and the word spans are aria-hidden, so it is read once and not word by word. Reduced motion shows the heading at rest with no animation.

Found on