Text Animations||6 min read

Blur-to-Sharp Text Reveal with Zero JavaScript

The Framer Motion blur text reveal rebuilt in CSS. A scroll trigger starts an ordinary keyframe animation once, so the stagger stays on the clock and the library leaves the bundle.

The Framer Motion blur text asks JavaScript for two things: a hook that notices when the text scrolls into view, and a library to run the staggered blur. CSS now does both. One property watches the scroll position, the animation stays a plain keyframe on a clock, and the props match, so the swap is one import.

The final result

Scroll inside the preview. Nothing moves until the text is 30% of the way through its trip across the box, then each word plays through once and stays sharp however you scroll after that.

What we are building

CSS has two ways to tie an animation to scrolling. A scroll-driven animation is welded to the scrollbar, following your thumb and running backwards when you scroll up. That is what the scroll reveal CSS post builds. The Framer version instead waits for the text to come into view, then plays a half second animation on a clock, once.

Scroll-triggered animations are the CSS version of exactly that. timeline-trigger on the wrapper turns its trip through the scrollport into a switch. animation-trigger on each segment says what to do when the switch flips. Everything else is the animation you would write anyway, with animation-delay doing the stagger that transition.delay does in the twin.

Setting up

Nothing to install, and moving over is a one line change.

tsx
// before
import BlurText from './BlurText';
// after
import BlurText from './BlurTextCSS';

What you do have to decide is the fallback. Triggers shipped in Chrome and Edge 146 and are not in Safari or Firefox yet. Both of those do run scroll-driven animations, Safari since version 26 and Firefox since version 159, released on 27 October 2026. So the component gives them a scrubbed version of the same keyframes, and any browser with neither gets plain, readable text.

Step 1: Give every segment an index

tsx
let index = -1;
const segment = (content: string, key: string) => {
  index += 1;
  return (
    <span key={key} className='btcss-seg' style={{ '--i': String(index) } as CSSProperties}>
      {content}
    </span>
  );
};

The text is split on whitespace, which stays as plain text between the spans so lines still wrap. In characters mode each word goes into a white-space: nowrap box first and its letters become segments inside it. The Framer version splits on every character directly, which lets a line break in the middle of a word. The counter runs across words, so the wave travels through the sentence in reading order.

Split text reads as loose letters to a screen reader. The wrapper carries the sentence once in a visually hidden span and the animated copy is aria-hidden.

Step 2: The animation, on a clock

css
@keyframes btcss-in {
  from { opacity: 0; filter: blur(12px); translate: 0 var(--btcss-y); }
  to   { opacity: 1; filter: blur(0px); translate: 0 0; }
}

.btcss-seg {
  display: inline-block;
  animation-name: btcss-in;
  animation-duration: 0.5s;
  animation-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1);
  animation-delay: calc(var(--i) * var(--btcss-delay));
  animation-fill-mode: both;
}

These are the twin's numbers: 12px of blur, a 20px rise, half a second and the same easing curve. --btcss-delay is the delay prop in seconds, so multiplying it by the index reproduces i * delay. The inline-block is not optional, because translate does nothing to a plain inline span. With delay={0} the whole line arrives as one sheet:

On its own this rule plays the moment the page loads, so a reader further down the page would arrive at text that finished animating long ago.

Step 3: Start it from the scrollbar

css
.btcss {
  timeline-trigger: --btcss view() cover 30% cover 100%;
  trigger-scope: --btcss;
}

.btcss-seg {
  animation-trigger: --btcss play-once;
}

timeline-trigger names a trigger and gives it a source. view() is the wrapper's own trip through its nearest scroll container, the same timeline a scroll-driven animation would use. The two cover offsets are the activation range, so the trigger fires once the wrapper is 30% of the way through that trip, which for one line in a full window is just under a third of the way up. That is the job margin: '-50px' does in useInView.

animation-trigger points each segment at that name and picks an action. play-once plays the animation and then never again, which is once: true. Until then the animation waits at its start without playing. animation-fill-mode: both makes that wait, and each segment's delay after it, show the first keyframe, so every segment holds its blurred frame and nothing flashes before the trigger fires.

The trigger-scope line is there because trigger names are global by default. The spec combines every element that declares the same name into one trigger, and every copy of this component declares --btcss. trigger-scope: --btcss on the wrapper keeps the name inside it, so each line listens only to its own wrapper.

Where this runs out

The trigger is the catch. Chromium is the only engine with it so far, so a Safari or Firefox reader never sees the clock version. In the component the rules from Step 2 and Step 3 sit inside @supports (timeline-trigger: --t view() entry / cover). Safari and Firefox skip that block and get a second one, which drives the same keyframes from a named view timeline instead.

css
@supports (animation-timeline: view()) and (not (timeline-trigger: --t view() entry / cover)) {
  .btcss { view-timeline: --btcss-line block; }
  .btcss-seg {
    animation-name: btcss-in;
    animation-fill-mode: both;
    animation-timing-function: linear;
    animation-timeline: --btcss-line;
    animation-range: cover calc(25% + var(--i) * var(--btcss-step))
                     cover calc(45% + var(--i) * var(--btcss-step));
  }
}

It sets longhands only, because the animation shorthand resets animation-timeline to auto.

That version scrubs. It runs backwards when the reader scrolls up, and its stagger is measured in scroll distance, so --btcss-step converts the delay into a slice of the timeline. It is the delay times 30 as a percentage, so the default 0.05 starts each segment 1.5% further along. A good reveal, just not the same one. If the effect has to look identical in every browser today, the Framer twin is still the one to ship.

Two directions from here. The trigger and the animated element do not have to be the same box, so put timeline-trigger on a section heading and the paragraph under it sharpens when the heading arrives. Names are global, so with several heading and paragraph pairs on one page, give each pair a shared parent with trigger-scope set to the trigger's name. Or swap play-once for play-forwards play-backwards and the text blurs back out when it leaves the trigger's range, on the clock rather than the scrollbar.