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.