Footer Reveal
ScrollThe page content slides up to reveal a footer that is already in place underneath.
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
Chrome 120 · Firefox 103 · Safari 18; needs backdrop-filter, mask-image. Elsewhere: a gradient to the panel colour instead of a blur.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/progressive-blur.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
Unchanged. The component has no motion; what moves is the page.
ClientRouterCSS-only; nothing to rebind.
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.
Infinite scrolling strip for logos, quotes or tags in any direction.
Placeholder lines, optionally with an avatar, shimmering while content loads.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown