---
name: scroll-blur-reveal
description: Use when you want "Premium, cinematic, modern" - Removes blur as content appears while scrolling.
---

# Scroll Blur Reveal

> **Category:** Scroll  -  **Personality:** Premium, cinematic, modern
>
> **Best use:** Hero copy, image sections
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

Elements start out-of-focus — `filter: blur()`, slightly scaled up, faded and nudged down — and **rack into focus** (blur → 0, scale → 1, fade in) the moment they scroll into view. It mimics a camera pulling focus, which reads as expensive and intentional, so it suits hero headlines and large image sections where you want each frame to "land" as the reader arrives. An `IntersectionObserver` fires the reveal once per element; everything else is a single CSS transition. Under `prefers-reduced-motion: reduce` (or with no JS) the content is simply sharp and visible from the start.

## Dependencies / CDN

**None — pure CSS transition + ~12 lines of vanilla JS** (`IntersectionObserver`, supported in every modern browser). The demo loads two display fonts; optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Tag anything you want to reveal with `.reveal` (add `.reveal--img` for heavier blur on photos). The wrapper carries the `is-ready` flag that JS sets — so the page degrades to "sharp & visible" if JS never runs.

```html
<div class="stage">
  <h2 class="reveal">We chase the light until the world holds still.</h2>
  <p class="reveal" style="--d:.12s">A field journal from the edge of the North Atlantic.</p>

  <figure class="reveal reveal--img">
    <img src="/img/landscape-coast.jpg" alt="A headland at first light" loading="lazy" width="1536" height="1024">
  </figure>

  <!-- stagger a row by giving each item an increasing --d delay -->
  <div class="gallery">
    <figure class="reveal reveal--img" style="--d:0s">…</figure>
    <figure class="reveal reveal--img" style="--d:.12s">…</figure>
    <figure class="reveal reveal--img" style="--d:.24s">…</figure>
  </div>
</div>
```

## CSS

```css
/* DEFAULT = sharp & visible (no-JS / reduced-motion safe).
   The blurred start-state only exists once JS adds .is-ready to the wrapper. */
.is-ready .reveal{
  filter: blur(14px);
  opacity: 0;
  transform: translateY(28px) scale(1.045);
  transition: filter   1.15s cubic-bezier(.16,.84,.34,1),
              opacity  1.15s cubic-bezier(.16,.84,.34,1),
              transform 1.15s cubic-bezier(.16,.84,.34,1);
  transition-delay: var(--d, 0s);          /* per-element stagger */
  will-change: filter, opacity, transform;
}
.is-ready .reveal--img{                     /* photos get a stronger focus-pull */
  filter: blur(22px);
  transform: translateY(34px) scale(1.085);
}
.is-ready .reveal.is-in{                     /* the revealed state */
  filter: blur(0);
  opacity: 1;
  transform: none;
}

/* Respect the user's motion preference: never hide, never move. */
@media (prefers-reduced-motion: reduce){
  .reveal, .is-ready .reveal{
    filter: none !important; opacity: 1 !important;
    transform: none !important; transition: none !important;
  }
}
```

## JavaScript

```js
(function(){
  var stage = document.querySelector('.stage');
  if(!stage) return;
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

  // Bail out → content stays sharp/visible (we never add the blurred start-state).
  if(reduce || !('IntersectionObserver' in window)) return;

  stage.classList.add('is-ready');           // run at end of <body> → no flash

  var io = new IntersectionObserver(function(entries){
    entries.forEach(function(en){
      if(en.isIntersecting){
        en.target.classList.add('is-in');
        io.unobserve(en.target);              // reveal once, then stop watching
      }
    });
  }, { threshold: 0.18, rootMargin: '0px 0px -12% 0px' });

  stage.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
})();
```

## How it works

- **The reveal is one CSS transition.** The element sits at `blur(14px)` + `opacity:0` + a small `translateY`/`scale`; adding the `.is-in` class transitions all three to their resting values. The blur dropping to `0` is the "focus pull"; the scale settling from `1.045 → 1` makes it feel like it is moving toward you.
- **`IntersectionObserver` decides *when*.** Each `.reveal` is observed; the first time it crosses the threshold it gets `.is-in` and is then `unobserve`d — so it reveals exactly once and costs nothing afterward.
- **`threshold: 0.18` + `rootMargin: '0px 0px -12% 0px'`** trigger the reveal slightly *before* the element is fully on screen, so motion finishes around the time it reaches comfortable reading position.
- **Progressive enhancement.** The blurred start-state lives behind a `.is-ready` class that JS adds. No JS (or reduced motion) → the class is never added → the content is plain and sharp. Because the script runs at the end of `<body>`, the class is set before first paint, so there is no flash of un-blurred content.
- **Stagger** is just `transition-delay: var(--d)`. Give items in a row increasing `--d` values and they rack into focus in sequence.

## Customization

- **Focus depth:** raise the start blur (`blur(20px)`+) for a softer, slower-feeling reveal; lower it (`blur(8px)`) for a snappy, subtle one. Keep image blur a few px higher than text.
- **Direction / drama:** change `translateY(28px)` to `translateX(...)` for a slide-in, or drop it for a pure focus-pull with no movement.
- **Pacing:** the `cubic-bezier(.16,.84,.34,1)` ease-out gives the cinematic "arrive and settle" feel; lengthen the `1.15s` duration for more luxury, shorten for more utility.
- **Pure-CSS variant (no JS):** modern browsers support scroll-driven animations — replace the JS with `animation-timeline: view()` and an `@keyframes` that animates `filter`/`opacity`/`transform`. Keep the `prefers-reduced-motion` guard.

## Accessibility & performance

- **Always provide a no-motion path.** The `prefers-reduced-motion` block plus the JS bail-out guarantee the content is fully visible and sharp for users who opt out — never trap copy behind an animation.
- **No-JS safety:** because the hidden state is gated behind `.is-ready`, search engines and JS-disabled visitors still get all the content.
- **`filter: blur()` is GPU work.** It is cheap here because each element animates **once** and is then unobserved; `will-change` is set only while it matters. Avoid leaving dozens of permanently-blurred elements on the page.
- Add `width`/`height` (or `aspect-ratio`) to images so the reveal does not cause layout shift, and use `loading="lazy"` for below-the-fold media.

## Gotchas

- **Don't start at `opacity:0` unconditionally.** If the blurred/hidden state is the default (not gated behind a JS-added class), a JS failure hides your whole page. Gate it behind `.is-ready`.
- **Run the script at the end of `<body>`** (or set the class before first paint). Defer it too late and users briefly see the sharp version snap to blurred.
- **Don't re-observe.** Call `io.unobserve(target)` after revealing, or fast scrolling can re-trigger / thrash the transition.
- **`will-change: filter` on hundreds of nodes** wastes memory — keep it on the handful that are actually animating.
- **A parent with `overflow:hidden` + `border-radius`** clips fine, but if an ancestor sets its own `filter`, it creates a containing block that can subtly change how blurs composite — keep the reveal elements free of ancestor `filter`s where you can.
</content>
</invoke>
