Skip to content
← components

Progressive Blur

new

A graduated blur — stacked backdrop-filter layers, each masked to an overlapping gradient band, so the blur ramps smoothly instead of stepping. Shown fading the edges of a marquee.

Orbit
Prism
Vertex
Loop
Quanta
Flux
Cobalt
Ember

Usage

example.tsx
import { ProgressiveBlur } from "~/components/effects/progressive-blur";
// Fade the left edge of scrolling content into blur
<div className="relative overflow-hidden">
<Marquee>{logos}</Marquee>
<ProgressiveBlur
direction="to-left"
blurEnd={8}
layers={5}
className="absolute inset-y-0 left-0 z-10 w-40"
/>
</div>

Why stack layers

A single backdrop-filter: blur() is uniform: everything behind it gets the same blur, with a hard edge where the element ends. To make blur ramp, you stack several blur layers and mask each one to a different band.

The blur amounts grow geometrically (1, 1.7, 2.8, 4.8, 8…), and each layer is masked with a linear-gradient that fades in, holds, and fades out over a slice of the width. Because neighbouring bands overlap, the blurs cross-fade into each other and you read one continuous gradient of blur rather than five discrete steps. Point the gradient with direction and drop one over each edge of a marquee for the effect above.

progressive-blur.tsx
// Each layer's blur grows geometrically; each is masked to a band that
// overlaps its neighbours, so the blur ramps instead of stepping.
const step = 1 / (layers + 1);
const blurBase = blurRange ** (1 / (layers - 1));
layers.map((layer) => (
<div style={{
backdropFilter: `blur(${blurBase ** layer}px)`,
maskImage: `linear-gradient(to left,
transparent ${layer * step * 100}%,
black ${(layer + 1) * step * 100}%,
black ${(layer + 2) * step * 100}%,
transparent ${(layer + 3) * step * 100}%)`,
}} />
));

Props

proptypedefaultdescription
direction"to-left" | "to-right" | "to-top" | "to-bottom""to-bottom"Which way the blur deepens.
blurStartnumber0Blur (px) at the shallow edge.
blurEndnumber12Blur (px) at the deep edge.
layersnumber5Layer count. More = smoother ramp.
…propsHTMLAttributesclassName/style for size + position.

Recreated from frostin-ui's ProgressiveBlur.

The full implementation. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).

components/effects/progressive-blur.tsx
import { cn } from "~/lib/utils";
export type BlurDirection = "to-left" | "to-right" | "to-top" | "to-bottom";
const DIR_CSS: Record<BlurDirection, string> = {
"to-left": "to left",
"to-right": "to right",
"to-top": "to top",
"to-bottom": "to bottom",
};
function linearMask(
direction: BlurDirection,
opacities: number[],
positions: number[]
) {
const stops = positions
.map((p, i) => `rgba(0,0,0,${opacities[i]}) ${(p * 100).toFixed(3)}%`)
.join(", ");
return `linear-gradient(${DIR_CSS[direction]}, ${stops})`;
}
export interface ProgressiveBlurProps
extends React.HTMLAttributes<HTMLDivElement> {
direction?: BlurDirection;
/** Blur (px) at the shallow edge. Default 0. */
blurStart?: number;
/** Blur (px) at the deep edge. Default 12. */
blurEnd?: number;
/** Number of blur layers. More = smoother ramp. Default 5. */
layers?: number;
}
/**
* A progressive (graduated) blur: a stack of layers whose `backdrop-filter`
* blur grows geometrically, each masked to an overlapping gradient band, so the
* blur ramps smoothly across the element instead of stepping. Place it over the
* edge of scrolling content for a clean fade-to-blur. Recreated, dependency-free,
* from frostin-ui's ProgressiveBlur.
*/
export function ProgressiveBlur({
direction = "to-bottom",
blurStart = 0,
blurEnd = 12,
layers = 5,
className,
style,
children,
...rest
}: ProgressiveBlurProps) {
const n = Math.max(layers, 2);
const step = 1 / (n + 1);
const blurMin = Math.min(blurStart, blurEnd);
const blurMax = Math.max(blurStart, blurEnd);
const blurRange = blurMax - blurMin;
const blurBase = blurRange ** (1 / (n - 1));
return (
<div className={cn("relative", className)} style={style} {...rest}>
{children}
<div
className="pointer-events-none absolute inset-0"
style={{
backdropFilter: `blur(${blurMin}px)`,
WebkitBackdropFilter: `blur(${blurMin}px)`,
}}
/>
{Array.from({ length: n }, (_, layer) => {
const bf = `blur(${(blurMin + blurBase ** layer).toFixed(3)}px)`;
const positions = [
layer * step,
(layer + 1) * step,
(layer + 2) * step,
(layer + 3) * step,
];
const img = linearMask(direction, [0, 1, 1, 0], positions);
return (
<div
key={layer}
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{
backdropFilter: bf,
WebkitBackdropFilter: bf,
maskImage: img,
WebkitMaskImage: img,
}}
/>
);
})}
</div>
);
}