Progressive Blur

Top and bottom edge overlays that use stacked backdrop-filter layers to create a smooth, progressive blur effect — no color fade needed.

Made by Léo
Open in
  • The quick brown fox jumps over the lazy dog.
  • Pack my box with five dozen liquor jugs.
  • How vexingly quick daft zebras jump!
  • The five boxing wizards jump quickly.
  • Sphinx of black quartz, judge my vow.
  • Two driven jocks help fax my big quiz.
  • Five quacking zephyrs jolt my wax bed.
  • The jay, pig, fox, zebra and my wolves quack!
  • Blowzy red vixens fight for a quick jump.
  • Jumpy halfback vows to protect the dizzy quarterback.
  • Bright vixens jump; dozy fowl quack.
  • Quick wafting zephyrs vex bold Jim.
  • Quick zephyrs blow, vexing daft Jim.
  • Sex-charged fop blew my junk TV quiz.
  • How quickly daft jumping zebras vex.
  • Two driven jocks help fax my big quiz.
demo-progressive-blur.tsx
"use client";

import {
  ProgressiveBlur,
  type ProgressiveBlurProps,
} from "@/components/unlumen-ui/progressive-blur";

const LINES = [
  "The quick brown fox jumps over the lazy dog.",
  "Pack my box with five dozen liquor jugs.",
  "How vexingly quick daft zebras jump!",
  "The five boxing wizards jump quickly.",
  "Sphinx of black quartz, judge my vow.",
  "Two driven jocks help fax my big quiz.",
  "Five quacking zephyrs jolt my wax bed.",
  "The jay, pig, fox, zebra and my wolves quack!",
  "Blowzy red vixens fight for a quick jump.",
  "Jumpy halfback vows to protect the dizzy quarterback.",
  "Bright vixens jump; dozy fowl quack.",
  "Quick wafting zephyrs vex bold Jim.",
  "Quick zephyrs blow, vexing daft Jim.",
  "Sex-charged fop blew my junk TV quiz.",
  "How quickly daft jumping zebras vex.",
  "Two driven jocks help fax my big quiz.",
];

type ProgressiveBlurDemoProps = Pick<
  ProgressiveBlurProps,
  "strength" | "size" | "tint"
>;

export const ProgressiveBlurDemo = ({
  strength = 4,
  size = "160px",
  tint = true,
}: ProgressiveBlurDemoProps) => {
  return (
    <div className="relative h-[500px] w-full  overflow-hidden rounded-lg border border-border bg-background">
      {/* Top blur */}
      <ProgressiveBlur side="top" strength={strength} size={size} tint={tint} />

      {/* Scrollable content */}
      <div className="h-full overflow-y-auto">
        <ul className="space-y-3">
          {LINES.map((line, i) => (
            <li
              key={i}
              className="text-4xl p-12 pb-1 text-foreground font-medium tracking-tight"
            >
              {line}
            </li>
          ))}
        </ul>
      </div>

      {/* Bottom blur */}
      <ProgressiveBlur
        side="bottom"
        strength={strength}
        size={size}
        tint={tint}
      />
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/progressive-blur.tsx
"use client";

import * as React from "react";

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

export type ProgressiveBlurSide = "top" | "bottom" | "left" | "right";

export interface ProgressiveBlurProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Which edge the blur is strongest at.
   * @default "bottom"
   */
  side?: ProgressiveBlurSide;
  /**
   * Maximum backdrop blur in pixels (at the edge).
   * @default 16
   */
  strength?: number;
  /**
   * Number of blur layers. More = smoother gradient, slightly heavier.
   * @default 8
   */
  layers?: number;
  /**
   * Thickness of the blurred area (height for top/bottom, width for left/right).
   * Accepts any valid CSS length value.
   * @default "128px"
   */
  size?: string | number;
  /**
   * Whether to add a background-color tint fade alongside the blur.
   * Uses `--background` / `hsl(var(--background))` so it matches your theme.
   * @default true
   */
  tint?: boolean;
  className?: string;
}

// gradient goes FROM the edge (strong) TOWARD the interior (weak)
const GRADIENT_DIR: Record<ProgressiveBlurSide, string> = {
  top: "to bottom",
  bottom: "to top",
  left: "to right",
  right: "to left",
};

function pct(n: number) {
  return `${(n * 100).toFixed(2)}%`;
}

export function ProgressiveBlur({
  side = "bottom",
  strength = 4,
  layers = 8,
  size = "160px",
  tint = true,
  className,
  style,
  ...props
}: ProgressiveBlurProps) {
  const dir = GRADIENT_DIR[side];

  // geometric scale so perceptual difference between steps is even;
  // each layer masks from 0% to i/layers, fading out at (i+1)/layers
  const blurAt = (i: number) => {
    // blurAt(0) = strength, blurAt(layers-1) ≈ 0.4px
    const min = 0.4;
    return +(strength * Math.pow(min / strength, i / (layers - 1))).toFixed(2);
  };

  const sizeStyle: React.CSSProperties =
    side === "left" || side === "right"
      ? {
          width: typeof size === "number" ? `${size}px` : size,
          height: "100%",
          top: 0,
        }
      : {
          height: typeof size === "number" ? `${size}px` : size,
          width: "100%",
        };

  const positionStyle: React.CSSProperties =
    side === "top"
      ? { top: 0, left: 0 }
      : side === "bottom"
        ? { bottom: 0, left: 0 }
        : side === "left"
          ? { top: 0, left: 0 }
          : { top: 0, right: 0 };

  return (
    <div
      aria-hidden="true"
      className={cn("pointer-events-none absolute z-10", className)}
      style={{ ...sizeStyle, ...positionStyle, ...style }}
      {...props}
    >
      {tint && (
        <div
          className="absolute inset-0"
          style={{
            background: `linear-gradient(${dir}, hsl(var(--background)) 0%, transparent 100%)`,
          }}
        />
      )}

      {Array.from({ length: layers }, (_, i) => {
        const blur = blurAt(i);
        const maskGradient = `linear-gradient(${dir}, black 0%, black ${pct(i / layers)}, transparent ${pct((i + 1) / layers)})`;

        return (
          <div
            key={i}
            className="absolute inset-0"
            style={{
              backdropFilter: `blur(${blur}px)`,
              WebkitBackdropFilter: `blur(${blur}px)`,
              maskImage: maskGradient,
              WebkitMaskImage: maskGradient,
            }}
          />
        );
      })}
    </div>
  );
}

Update the import paths to match your project setup.

Usage

import { ProgressiveBlur } from "@/components/unlumen-ui/progressive-blur";

// Wrap your scrollable container in `relative overflow-hidden`, then add:
<div className="relative h-64 overflow-hidden">
  <ProgressiveBlur side="top" />

  <div className="h-full overflow-y-auto">{/* content */}</div>

  <ProgressiveBlur side="bottom" />
</div>;

How it works

A single <ProgressiveBlur> renders N stacked layers, each with a backdrop-filter: blur value and a CSS mask gradient. Layer 0 (closest to the edge) carries the maximum blur; each subsequent layer has a smaller blur and a wider mask band. The result is a perceptually smooth ramp from full blur at the edge to nothing at the interior.

No canvas, no SVG filters — just CSS.

Props

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""bottom"The edge the blur is strongest at
strengthnumber16Max backdrop blur in pixels at the edge
layersnumber8Number of blur steps (more = smoother)
sizestring | number"128px"Thickness of the blurred area
tintbooleantrueAdds a background-color fade alongside the blur
classNamestringExtra class names on the wrapper

The component also forwards all standard HTMLDivElement props.

Examples

Bottom only

<div className="relative h-64 overflow-hidden">
  <div className="h-full overflow-y-auto">{/* ... */}</div>
  <ProgressiveBlur side="bottom" strength={20} size="96px" />
</div>

Horizontal (left / right)

<div className="relative flex overflow-hidden">
  <ProgressiveBlur side="left" size="64px" />
  <div className="flex gap-4 overflow-x-auto">{/* ... */}</div>
  <ProgressiveBlur side="right" size="64px" />
</div>

Without background tint

Set tint={false} to keep the pure blur effect without any colour overlay — useful over images or gradient backgrounds.

<ProgressiveBlur side="top" tint={false} strength={24} />

Built by Léo from Unlumen :3

Last updated: 3/15/2026