Progressive Blur
A gradual-blur edge treatment — stacked backdrop-filter layers with geometrically growing radii, each masked to its own slice, so content melts into frosted glass toward one edge.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/progressive-blur.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ProgressiveBlur" component — a gradual
blur edge treatment. Its only dependency is a cn() class merger (clsx +
tailwind-merge). No hooks, no events, no browser APIs and no animation, so it
must stay a server component — do NOT add "use client".
Contract
- export const ProgressiveBlur = React.forwardRef<HTMLDivElement,
ProgressiveBlurProps>, props extending Omit<React.ComponentProps<"div">,
"children"> (rest props and ref land on the root div) plus:
- edge?: "top" | "bottom" | "left" | "right" (default "bottom") — which side
the content dissolves into.
- intensity?: number (default 16) — blur radius in px of the STRONGEST layer,
the one sitting on the edge. Clamped 0..80; 0 leaves only the scrim.
- layers?: number (default 6) — how many masked backdrop layers build the
ramp. Clamped 2..12 and rounded.
- height?: number | string (default "28%") — thickness of the band. It is the
HEIGHT for edge top/bottom and the WIDTH for edge left/right; a number means
px, a string is any CSS length passed through as authored. Document that
axis flip in the JSDoc: the name follows the common case, the meaning
follows the edge.
- scrim?: number (default 0.7) — peak opacity of the theme-background gradient
painted under the blur. Clamped 0..1.
- It renders no children: it is a layer, not a wrapper.
- Every numeric blur/opacity prop (intensity, layers, scrim) goes through one
clamp helper that returns the default for non-finite input, so a consumer's
NaN can never reach `blur(NaNpx)`. `height` is not clamped, it is a CSS
length: a string goes through as authored, and only a number that cannot be a
thickness (non-finite or negative) falls back to the default — emitting
`NaNpx` there would make the browser drop the declaration, collapse the band
to 0 and delete the whole effect without a word.
Behavior
- Root: aria-hidden, pointer-events-none, absolute, pinned by a per-edge class
pair (inset-x-0 top-0 / inset-x-0 bottom-0 / inset-y-0 left-0 / inset-y-0
right-0) with the thickness written to `height` or `width` depending on the
axis. pointer-events-none is load-bearing: the band usually sits over a
scroller and must never swallow a touch drag.
- Render `layers` sibling divs, all absolute inset-0. Layer i gets
backdrop-filter: blur(r_i) (plus the -webkit- alias for older Safari) and a
mask-image (plus -webkit-mask-image) of
linear-gradient(<dir>, transparent (i-1)*step%, black i*step%,
black (i+1)*step%, transparent (i+2)*step%)
with step = 100 / layers and every stop clamped to 0..100. <dir> runs from
the calm inner side toward the edge ("to bottom" for edge="bottom", …).
The trapezoid windows overlap their neighbours by a full step — that overlap
is what turns discrete radii into one continuous ramp instead of visible
bands. The mask color keywords are alpha-only: `black` is never painted, only
its alpha is read, so the band works over any backdrop.
- Radii grow geometrically toward the edge: r_i = intensity * 2^(-(n-1-i)*d)
with d = min(1, 5 / (n-1)). Dividing the 5 octaves by the step count keeps a
12-layer stack from bottoming out at 0.008px (compositing passes that render
nothing); the min(1, …) cap keeps a short stack from stepping by more than 2x
at a time. The ramp therefore spans AT MOST 32x and never more than 2x per
layer: 6 layers or more cover the full range (six step by exact doubling,
0.5 → 1 → 2 → 4 → 8 → 16, more subdivide it), while 2, 3, 4 and 5 layers span
2x, 4x, 8x and 16x. Say that in the docs rather than claiming a flat 32x — a
reader who drops to 3 layers must know the weakest layer is intensity/4, not
intensity/32, or the shallow fade looks like a bug.
- Stacking is the mechanism: each layer's backdrop is the composite of what was
painted before it, so the layers accumulate into a smooth ramp rather than
fighting each other.
- After the layers — so it tints the frost instead of being blurred by it —
one scrim div paints
linear-gradient(<dir>, transparent, var(--zy-pblur-tint, var(--background))).
Its opacity comes from a class reading a CSS custom property set on the root,
NOT from an inline style — an inline declaration would outrank the @supports
override below and silently kill the fallback.
- Degradation: ship
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter:
blur(1px))) { .scrim { opacity: max(var(--scrim), 0.9) } .layer { display:
none } }
through a React 19 hoisted <style href precedence="medium"> tag (instances
dedupe by href). Where the browser cannot frost a backdrop the empty layers
stop compositing and the scrim alone carries a clean gradient fade, floored
strong enough to still read as an intentional treatment.
- Placement rules the consumer must know, and the docs must state: (1) mount it
AFTER the content it should blur — a backdrop filter only samples what is
painted before it; (2) keep that content inside the same backdrop root — an
ancestor with filter, opacity < 1, mask or isolation: isolate BETWEEN the
content and the band caps how far back the sampling reaches, while those same
properties on the shared ancestor that holds BOTH are harmless, sampling stays
complete; (3) a rounded container needs isolation: isolate (Tailwind
`isolate`) on exactly that shared ancestor — WebKit does not clip
-webkit-backdrop-filter to an ancestor's border-radius, so without it Safari
paints the band as a square patch overhanging the rounded corners; (4) overlay
UI (a CTA, a title bar) goes after the layer so it stays crisp on top of the
frost.
- Nothing animates and nothing is measured: there is no rAF, timer, listener or
observer to tear down, no window/document access, so SSR and hydration render
the same bytes, and prefers-reduced-motion has nothing to take away — the
treatment is fully present with motion off.
Rendering & styling
- Semantic tokens only: the scrim is var(--background) (overridable through
--zy-pblur-tint, e.g. var(--card) when the band sits on a card) and nothing
else is painted — no hex, rgb or oklch literals anywhere, so the treatment
re-skins itself with the host theme and dark mode for free.
- Merge the consumer className with cn() on the root so a call site can round
the band, retarget it (inset-x-8 for an inset fade) or restack it (z-20).
- Accessibility: the whole band is decoration — aria-hidden plus
pointer-events-none — so it neither announces itself nor intercepts the
keyboard, and the scroller it covers keeps its own focus ring and tab stop.
Customization levers
- Edge: `edge` is the only geometry switch; two instances on opposite edges
(left + right) soften both ends of a horizontal rail.
- Depth vs. thickness: `intensity` sets how frosted the far end gets,
`height` how long the journey is. 100–180px reads as an affordance;
50–65% of a short panel reads as a reading surface for overlaid copy.
- Smoothness vs. cost: `layers` 4–8 is the sweet spot; drop to 3 for a cheap
hint on a busy page — the 2x-per-step cap means 3 layers only span 4x, so
that is a shallower fade, not just a cheaper one — raise toward 10–12 only
when a long band shows banding.
- Tint: set --zy-pblur-tint on the instance (className or style) to fade into
--card, --popover or --sidebar instead of --background.
- Fallback strength: the 0.9 floor in the @supports block is the promise made
to browsers without backdrop-filter — raise it toward 1 for text-heavy
overlays, lower it if the artwork underneath must survive.
- Ramp shape: the exponent constant (5 octaves) is the single knob for how
front-loaded the ramp feels; lower it for a more linear, gauzier fade. It
only bites from 6 layers up — below that the 2x-per-step cap is what sets the
span.Concepts
- Blur as a ramp, not a line — a single
backdrop-blurleaves a hard boundary wherever its box ends; stacking masked layers with geometrically growing radii spreads that boundary across the whole band so the eye never finds an edge. The ramp spans at most 32x and never steps by more than 2x per layer, so 6+ layers cover the full range while 2/3/4/5 layers span 2x/4x/8x/16x — a 3-layer band is a shallower fade, not just a cheaper one. - Overlapping mask windows — each layer is opaque over one slice and ramps to transparent one slice either side, so neighbouring radii cross-fade; without the overlap the discrete steps show up as visible bands.
- Backdrop stacking order and backdrop root — a backdrop filter can only sample what was painted before it, which is why the band is mounted after the content it blurs and overlay UI after the band to stay crisp; and any ancestor with
filter,opacity < 1,maskorisolation: isolatebetween content and band caps how far back the sampling reaches, so keep both inside the same one or the frost has nothing to chew on. On the shared ancestor that holds both, the sameisolation: isolateis instead the fix you want: sampling stays complete, and it is what makes WebKit clip the band to a rounded box — without it Safari paints-webkit-backdrop-filterthrough the corner radius and the frost overhangs arounded-*card as a square patch. - Scrim as the graceful degradation — the theme-background gradient laid over the frost is both a contrast aid and the entire effect where
backdrop-filteris unsupported, where an@supportsrule floors its opacity and drops the now-useless layers. - Decoration that never intercepts —
aria-hiddenpluspointer-events-nonekeep the band out of the accessibility tree and out of the way of touch scrolling, so the list it covers still drags and still owns its focus ring. - Axis-dependent prop meaning —
heightis the band's height on the top/bottom edges and its width on the left/right ones: one thickness knob, read against whichever edge you pinned it to.