# Progressive Blur (Moonarc)

An edge that blurs what scrolls under it, more the closer it gets: a few bands, each with a backdrop blur twice the last and a mask that keeps it to its own slice, so the blur ramps instead of stepping. Nothing animates: the content moving beneath is the motion. Without backdrop-filter the edge fades to a colour. Zero JS.

- Import: `import ProgressiveBlur from '@moonarc/core/ProgressiveBlur'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/progressive-blur.json`
- Tier A · category background · trigger always
- Readout: `<ProgressiveBlur direction="bottom" layers={5}>`
- Browser support: widely (Chrome 120 · Firefox 103 · Safari 18); elsewhere: a gradient to the panel colour instead of a blur
- Measured cost: 0 B JS (CSS 5.4 kB raw)
- Page: https://moonarc.dev/components/progressive-blur/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `direction` | `'top' | 'bottom' | 'left' | 'right'` | `'bottom'` | Which edge the blur sits on. |
| `layers` | `number` | `5` | Bands, 2–8; more is smoother and costs more paint, because each band is a backdrop pass. |
| `strength` | `number` | `12` | Blur of the outermost band in px; each band inward is half. |
| `size` | `string` | `'6rem'` | Depth of the blurred edge, any CSS length. |
| `color` | `string` | `var(--ma-panel)` | The colour the edge lands on: a faint tint over the last band, the whole fade where backdrop-filter is missing. |

## Usage

```astro
<!-- the blur is a sibling of the scroller, inside a positioned wrapper: inside the scroller it would scroll away -->
<div class="relative">
  <div class="h-80 overflow-y-auto">
    <ul>…</ul>
  </div>
  <ProgressiveBlur direction="bottom" size="5rem" />
</div>
```

## Reduced motion

Unchanged. The component has no motion; what moves is the page.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Blur doubles per band (strength / 2ⁿ inward): a linear ramp of blur radii looks stepped because the eye reads blur logarithmically.
- Each band's mask overlaps its neighbours by one slice, so the transitions between blur radii are cross-fades rather than seams.
- Five bands by default: measured on the catalogue, more bands raised paint cost without a visible gain at 12 px.
- The last band also tints toward the panel colour so the edge lands on the surface instead of a blurred cut; the no-backdrop fallback is that same gradient at full strength.
- pointer-events: none, so the content under the edge stays scrollable and clickable.
- It is absolutely positioned on its parent, so it goes beside the scroller, not in it: an absolute child of a scroll container scrolls with the content and the edge would leave with the first rows.

## Replaces

- ProgressiveBlur (Magic UI)
- ProgressiveBlur (Motion Primitives)

## Source

```astro
---
/**
 * ProgressiveBlur — an edge that blurs what scrolls under it, more the
 * closer it gets. `layers` absolutely positioned bands, each with a
 * backdrop blur twice the previous one's and a mask that keeps it to its
 * own slice of the edge, so the blur ramps instead of stepping. Nothing
 * animates; the content moving under it is the motion. Without
 * backdrop-filter the edge fades to a colour instead. Sits on the given
 * edge of its positioned parent: put it beside the scroller, in a shared
 * positioned wrapper, since inside the scroller it would scroll away.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Which edge the blur sits on. */
  direction?: 'top' | 'bottom' | 'left' | 'right';
  /** Bands; more is smoother and dearer to paint. */
  layers?: number;
  /** Blur of the outermost band in px. */
  strength?: number;
  /** Depth of the blurred edge, any CSS length. */
  size?: string;
  /** Colour the edge fades to where backdrop-filter is missing (and, faintly, on top of the blur). */
  color?: string;
}

const { direction = 'bottom', layers = 5, strength = 12, size = '6rem', color = 'var(--ma-panel)', class: className, style, ...rest } = Astro.props;
const n = Math.max(2, Math.min(8, Math.round(layers)));
const inline = [`--ma-pblur-n:${n}`, `--ma-pblur-strength:${strength}px`, `--ma-pblur-size:${size}`, `--ma-pblur-color:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// each band: blur = strength / 2^(n-1-i); mask keeps it between (i-1)/n and (i+1)/n of the depth, solid in the middle
const bands = Array.from({ length: n }, (_, i) => ({ i, blur: strength / 2 ** (n - 1 - i), a: Math.max(0, ((i - 1) / n) * 100), b: (i / n) * 100, c: Math.min(100, ((i + 1) / n) * 100) }));
---

<div class:list={['ma-pblur', className]} aria-hidden="true" data-direction={direction} style={inline} {...rest}>
  {bands.map((b) => <i style={`--ma-pblur-b:${b.blur}px;--ma-pblur-m0:${b.a}%;--ma-pblur-m1:${b.b}%;--ma-pblur-m2:${b.c}%`}></i>)}
</div>

<style is:global>
  @layer components {
    :where(.ma-pblur) {
      position: absolute;
      inset: auto 0 0;
      height: var(--ma-pblur-size, 6rem);
      pointer-events: none;
      /* the mask runs from the inside edge (0 %) to the outside edge (100 %) */
      --ma-pblur-to: to bottom;
    }
    :where(.ma-pblur[data-direction='top']) {
      inset: 0 0 auto;
      --ma-pblur-to: to top;
    }
    :where(.ma-pblur[data-direction='left']) {
      inset: 0 auto 0 0;
      width: var(--ma-pblur-size, 6rem);
      height: auto;
      --ma-pblur-to: to left;
    }
    :where(.ma-pblur[data-direction='right']) {
      inset: 0 0 0 auto;
      width: var(--ma-pblur-size, 6rem);
      height: auto;
      --ma-pblur-to: to right;
    }
    :where(.ma-pblur > i) {
      position: absolute;
      inset: 0;
      -webkit-backdrop-filter: blur(var(--ma-pblur-b));
      backdrop-filter: blur(var(--ma-pblur-b));
      -webkit-mask-image: linear-gradient(var(--ma-pblur-to), transparent var(--ma-pblur-m0), #000 var(--ma-pblur-m1), #000 var(--ma-pblur-m2), transparent calc(var(--ma-pblur-m2) + (var(--ma-pblur-m2) - var(--ma-pblur-m1))));
      mask-image: linear-gradient(var(--ma-pblur-to), transparent var(--ma-pblur-m0), #000 var(--ma-pblur-m1), #000 var(--ma-pblur-m2), transparent calc(var(--ma-pblur-m2) + (var(--ma-pblur-m2) - var(--ma-pblur-m1))));
    }
    /* the last band also tints toward the colour so the edge lands on the panel instead of a blurred cut */
    :where(.ma-pblur > i:last-child) {
      background: linear-gradient(var(--ma-pblur-to), transparent, color-mix(in srgb, var(--ma-pblur-color, var(--ma-panel)) 60%, transparent));
    }
    /* no backdrop-filter: one gradient to the colour */
    @supports not (backdrop-filter: blur(1px)) {
      :where(.ma-pblur > i) {
        display: none;
      }
      :where(.ma-pblur > i:last-child) {
        display: block;
        mask-image: none;
        -webkit-mask-image: none;
        background: linear-gradient(var(--ma-pblur-to), transparent, var(--ma-pblur-color, var(--ma-panel)));
      }
    }
    /* nothing moves here; the branch is the same edge — stated so the audit sees it */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-pblur) {
        pointer-events: none;
      }
    }
  }
</style>

```
