Progressive blur for the web — a stack of backdrop-filter layers masked by gradients, capped with a surface tint. It is the technique behind frosted sticky headers (ChatGPT, Figma, Linear, …): the content under the bar blurs more the closer it gets to the edge.
- Framework-agnostic core — pure math, zero dependencies, SSR-safe.
- React, Vue 3, vanilla DOM and a
<framed-blur>Web Component — same options everywhere. - Zero-JS CSS drop-in (
styles.css) for anyone who just wants the effect. - Accessible by default: respects
prefers-reduced-transparencyandforced-colors. - Ships ESM + CJS + types (verified with
publintand@arethetypeswrong/cli).
npm i framed-blur
# pnpm add framed-blur · yarn add framed-blur · bun add framed-blurimport { ProgressiveBlur } from 'framed-blur/react'; // React
import { ProgressiveBlur } from 'framed-blur/vue'; // Vue 3
import 'framed-blur/element'; // <framed-blur>
import 'framed-blur/styles.css'; // zero-JSShips a single
ProgressiveBlurAPI. The overlay ispointer-events: noneandaria-hidden, so it never steals input or gets announced.
A single backdrop-filter: blur() is uniform. To get a gradient of blur you stack several layers, each with a bigger radius, and limit how far it reaches with a mask-image linear-gradient:
| layer | blur | mask ramp |
|---|---|---|
| 1 | blur/8 |
#000 75% → transparent 100% |
| 2 | blur/4 |
#000 62.5% → transparent 87.5% |
| 3 | blur/2 |
#000 50% → transparent 75% |
| 4 | blur |
#000 37.5% → transparent 62.5% |
| tint | — | surface/60% → transparent |
With the defaults (levels: 4, blur: 4) that is 0.5 / 1 / 2 / 4px. The ramp is fully parametric: for N layers the blur doubles per layer and the mask is staggered by spread / (2N), so stronger layers reach a shorter distance.
The container is pointer-events: none and aria-hidden, so it never intercepts input or gets announced.
import { ProgressiveBlur } from 'framed-blur/react';
export function Inbox() {
return (
<div className="relative h-[420px] overflow-y-auto">
<ProgressiveBlur height={96} levels={4} blur={4} tint="var(--surface)" tintOpacity={0.6} />
{/* sticky header + scrollable content */}
</div>
);
}Drive it from scroll if you like — just animate opacity:
<ProgressiveBlur opacity={scrollTop > 0 ? 1 : 0} style={{ transition: 'opacity .15s' }} /><script setup lang="ts">
import { ProgressiveBlur } from 'framed-blur/vue';
</script>
<template>
<div class="relative h-[420px] overflow-y-auto">
<ProgressiveBlur :height="96" :levels="4" :blur="4" tint="var(--surface)" :tint-opacity="0.6" />
<!-- content -->
</div>
</template>Props mirror the options below (kebab-case in templates). useReducedTransparency() is exported for custom logic. Extra attributes/class/style fall through to the container.
import { createProgressiveBlur } from 'framed-blur';
const blur = createProgressiveBlur({ direction: 'bottom', height: 72, tint: '#0d0d0d' });
scrollArea.append(blur.element);
blur.update({ blur: 8 }); // re-render with new options
blur.destroy(); // clean up + remove listenersrenderProgressiveBlur(existingElement, options) renders into an element you own.
import 'framed-blur/element'; // registers <framed-blur><div style="position: relative; overflow: auto; height: 420px">
<framed-blur height="96" levels="4" blur="4" tint="#fff" direction="top"></framed-blur>
<!-- content -->
</div>Attributes mirror the options below; position and z-index control placement. The element re-renders when its attributes change.
<link rel="stylesheet" href="framed-blur/styles.css" />
<div class="pb" data-pb-edge="top">
<div></div><div></div><div></div><div></div><div></div>
</div>.pb { --pb-height: 96px; --pb-blur: 4px; --pb-tint: #fff; --pb-tint-opacity: 60%; }| Option | Type | Default | Description |
|---|---|---|---|
levels |
number |
4 |
Number of stacked blur layers (1..12). |
blur |
number |
4 |
Blur radius (px) of the strongest layer. |
height |
number |
96 |
Container size (px) along the fade axis. |
direction |
'top' | 'bottom' |
'top' |
Edge the blur fades in from. |
tint |
string | false |
'#ffffff' |
Tint painted above the blur, or false to disable. |
tintOpacity |
number |
0.6 |
Tint opacity at the strong edge (0..1). |
spread |
number |
1 |
Fraction of the container used by the mask ramp (0.05..1). |
ProgressiveBlurProps additionally accepts as, className, style, zIndex, opacity, position and respectReducedTransparency (React) — the Vue component exposes the equivalent props.
framed-blur—computeProgressiveBlur,renderProgressiveBlur,createProgressiveBlur, style/declaration helpers.framed-blur/core— pure functions only (computeProgressiveBlur,withAlpha,layerDeclarations,declarationsToStyle,toStyles, …).framed-blur/react—<ProgressiveBlur>,useReducedTransparency.framed-blur/vue—<ProgressiveBlur>(Vue 3),useReducedTransparency.framed-blur/element—<framed-blur>custom element +defineProgressiveBlur.framed-blur/styles.css— zero-JS drop-in.
computeProgressiveBlur(options): ProgressiveBlurModel
computeReducedTransparencyFallback(options): ProgressiveBlurModel
createProgressiveBlur(options): ProgressiveBlurHandle
renderProgressiveBlur(element, options): ProgressiveBlurModelUnder (prefers-reduced-transparency: reduce) or (forced-colors: active) the blur layers are dropped and replaced by a single solid-to-transparent tint (set respectReducedTransparency={false} to opt out). The overlay is always aria-hidden and pointer-events: none.
Each level is a compositor layer with a real backdrop-filter, which is not free. Prefer 4 levels unless you truly need smoother ramps; keep the container at the actual bar height; and animate opacity (not backdrop-filter) when fading it in. On low-end devices consider lowering levels or switching to the solid-tint fallback.
Chromium, Safari and Firefox support backdrop-filter + mask-image (with -webkit- prefixes emitted automatically). Where backdrop-filter is unsupported the overlay degrades to the tint gradient. backdrop-filter requires the element to be composited over the content it blurs — put the overlay inside the scroll container.
pnpm install
pnpm dev # React playground (./example)
pnpm dev:vue # Vue playground (./example-vue)
pnpm test # vitest
pnpm typecheck
pnpm build # tsup → dist (ESM + CJS + d.ts)
pnpm verify # typecheck + test + build + publint + attwMIT