Backgrounds

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.

Preview in your theme

Loading preview…

import * as React from "react"

import { cn } from "@/lib/utils"

/**
 * Ships with the component via React 19's hoisted <style> — no Tailwind config
 * edit, and several instances dedupe to one tag by href.
 *
 * The scrim opacity lives in a class instead of an inline style for the same
 * reason the animation shorthand does elsewhere in this library: an inline
 * declaration outranks the @supports override below, which would silently kill
 * the fallback. Where neither backdrop-filter nor its -webkit- alias exists the
 * blur layers paint nothing at all, so the scrim is the entire effect and is
 * floored at 0.9 — a browser that cannot frost the backdrop still gets a clean

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/progressive-blur.json

Prompt

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-blur leaves 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, mask or isolation: isolate between 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 same isolation: isolate is 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-filter through the corner radius and the frost overhangs a rounded-* 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-filter is unsupported, where an @supports rule floors its opacity and drops the now-useless layers.
  • Decoration that never interceptsaria-hidden plus pointer-events-none keep 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 meaningheight is 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.

On This Page