Skip to content

Components / Backgrounds

Progressive Blur

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 120 · Firefox 103 · Safari 18; needs backdrop-filter, mask-image. Elsewhere: a gradient to the panel colour instead of a blur.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ProgressiveBlur from '@moonarc/core/ProgressiveBlur';
---
<!-- 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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/progressive-blur.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

ProgressiveBlur.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>

Props

PropTypeDefaultDescription
direction'top' | 'bottom' | 'left' | 'right''bottom'Which edge the blur sits on.
layersnumber5Bands, 2–8; more is smoother and costs more paint, because each band is a backdrop pass.
strengthnumber12Blur of the outermost band in px; each band inward is half.
sizestring'6rem'Depth of the blurred edge, any CSS length.
colorstringvar(--ma-panel)The colour the edge lands on: a faint tint over the last band, the whole fade where backdrop-filter is missing.

Reduced motion

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

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: ProgressiveBlur (Magic UI) · ProgressiveBlur (Motion Primitives). See the migration table.

The page content slides up to reveal a footer that is already in place underneath.

every browserscroll

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover

Skeleton

Loading

Placeholder lines, optionally with an avatar, shimmering while content loads.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown