Progressive Blur

A single-layer backdrop-filter overlay with a CSS mask gradient — lightweight and GPU-friendly blur fade for any edge.

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

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";

// ─── Types ────────────────────────────────────────────────────────────────────

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;
  /** Blur amount in pixels. @default 4 */
  strength?: number;
  /** Thickness of the blurred area. @default "160px" */
  size?: string | number;
  /** Add a background-color tint fade alongside the blur. @default true */
  tint?: boolean;
  /** Opacity of the tint at the solid edge (0–1). @default 1 */
  tintStrength?: number;
  className?: string;
}

// ─── Module-level constants ───────────────────────────────────────────────────

const IS_HORIZONTAL: Record<ProgressiveBlurSide, boolean> = {
  top: false,
  bottom: false,
  left: true,
  right: true,
};

// Gradient goes FROM the edge (opaque) TO the content (transparent)
const FADE_DIR: Record<ProgressiveBlurSide, string> = {
  top: "to bottom",
  bottom: "to top",
  left: "to right",
  right: "to left",
};

const POSITION_STYLE: Record<ProgressiveBlurSide, React.CSSProperties> = {
  top: { top: 0, left: 0 },
  bottom: { bottom: 0, left: 0 },
  left: { top: 0, left: 0 },
  right: { top: 0, right: 0 },
};

// ─── Component ───────────────────────────────────────────────────────────────

export const ProgressiveBlur = React.memo(
  function ProgressiveBlur({
    side = "bottom",
    strength = 4,
    size = "160px",
    tint = true,
    tintStrength = 1,
    className,
    style,
    ...props
  }: ProgressiveBlurProps) {
    const isHorizontal = IS_HORIZONTAL[side];
    const fadeDir = FADE_DIR[side];
    const sizeValue = typeof size === "number" ? `${size}px` : size;

    const sizeStyle: React.CSSProperties = isHorizontal
      ? { width: sizeValue, height: "100%" }
      : { height: sizeValue, width: "100%" };

    const maskImage = `linear-gradient(${fadeDir}, black 50%, transparent 100%)`;

    const background = tint
      ? `linear-gradient(${fadeDir}, color-mix(in oklch, var(--background) ${Math.round(tintStrength * 100)}%, transparent) 0%, transparent 100%)`
      : undefined;

    return (
      <div
        aria-hidden="true"
        className={cn("pointer-events-none absolute z-10", className)}
        style={{
          ...sizeStyle,
          ...POSITION_STYLE[side],
          background,
          maskImage,
          WebkitMaskImage: maskImage,
          backdropFilter: `blur(${strength}px)`,
          WebkitBackdropFilter: `blur(${strength}px)`,
          willChange: "backdrop-filter",
          ...style,
        }}
        {...props}
      />
    );
  },
  (prev, next) =>
    prev.side === next.side &&
    prev.strength === next.strength &&
    prev.size === next.size &&
    prev.tint === next.tint &&
    prev.tintStrength === next.tintStrength &&
    prev.className === next.className,
);

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

Instead of stacking multiple backdrop-filter layers (which creates N compositing surfaces), this component uses a single backdropFilter: blur(Xpx) div masked with a CSS gradient. The mask goes from fully opaque at the edge to transparent at the interior, producing a smooth perceptual fade with minimal GPU overhead.

Tint is rendered via color-mix(in oklch, var(--background) N%, transparent) — one paint operation, no extra DOM node.

Props

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""bottom"The edge the blur is strongest at
strengthnumber4Blur amount in pixels
sizestring | number"160px"Thickness of the blurred area
tintbooleantrueAdds a background-color fade alongside the blur
tintStrengthnumber1Opacity of the tint at the solid edge (0–1)
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={8} 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={12} />

Custom tint opacity

<ProgressiveBlur side="bottom" tintStrength={0.6} strength={6} />

Built by Léo from Unlumen :3

Last updated: 4/29/2026