Progressive Blur
A stacked-layer approximation of an iOS-style progressive blur edge. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add progressive-blurpnpm dlx kosmesis add progressive-bluryarn dlx kosmesis add progressive-blurbunx kosmesis add progressive-blurCopy and paste the following code into your project.
import { StatelessComponent } from "@praxisjs/core";import { Component } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export type ProgressiveBlurSide = "top" | "bottom" | "left" | "right";export interface ProgressiveBlurProps { side?: ProgressiveBlurSide; size?: number; layers?: number; maxBlur?: number; /** Pass the sibling's measured scrollbar width (`el.offsetWidth - el.clientWidth`), not a guessed constant — it's OS/browser-dependent. */ scrollbarInset?: number; class?: string;}const GRADIENT_DIRECTION: Record<ProgressiveBlurSide, string> = { bottom: "to top", top: "to bottom", left: "to right", right: "to left",};const SIDE_CLASS: Record<ProgressiveBlurSide, string> = { bottom: "inset-x-0 bottom-0", top: "inset-x-0 top-0", left: "inset-y-0 left-0", right: "inset-y-0 right-0",};// CSS can't animate a single element's blur amount across a gradient, so this stacks `layers` divs, each blurred more and masked to a shrinking region.// Props are read once in `render()`, not reactively — remount this component when `scrollbarInset` (or any prop) needs to change.@Component()export class ProgressiveBlur extends StatelessComponent<ProgressiveBlurProps> { render() { const { side = "bottom", size = 96, layers = 6, maxBlur = 16, scrollbarInset = 0, class: cls } = this.props; const isVertical = side === "top" || side === "bottom"; const direction = GRADIENT_DIRECTION[side]; const sizeStyle = isVertical ? { height: `${String(size)}px` } : { width: `${String(size)}px` }; const crossInsetStyle = scrollbarInset > 0 ? (isVertical ? { right: `${String(scrollbarInset)}px` } : { bottom: `${String(scrollbarInset)}px` }) : undefined; return ( <div aria-hidden class={cn("pointer-events-none absolute", SIDE_CLASS[side], cls)} style={{ ...sizeStyle, ...crossInsetStyle }}> {Array.from({ length: layers }, (_, i) => { const blur = maxBlur * ((i + 1) / layers); const start = (i / layers) * 100; const end = ((i + 1) / layers) * 100; const mask = `linear-gradient(${direction}, transparent ${String(start)}%, black ${String(end)}%, black 100%)`; return ( <div key={i} class="absolute inset-0" style={{ backdropFilter: `blur(${String(blur)}px)`, maskImage: mask, }} /> ); })} </div> ); }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled } from "@praxisjs/css";import { Component } from "@praxisjs/decorators";class ProgressiveBlurStyles extends Stylesheet { $root = this.css({ pointerEvents: "none", position: "absolute" }); $bottom = this.css({ insetInline: "0", bottom: "0" }); $top = this.css({ insetInline: "0", top: "0" }); $left = this.css({ insetBlock: "0", left: "0" }); $right = this.css({ insetBlock: "0", right: "0" }); $layer = this.css({ position: "absolute", inset: "0" });}export type ProgressiveBlurSide = "top" | "bottom" | "left" | "right";export interface ProgressiveBlurProps { side?: ProgressiveBlurSide; size?: number; layers?: number; maxBlur?: number; /** Pass the sibling's measured scrollbar width (`el.offsetWidth - el.clientWidth`), not a guessed constant — it's OS/browser-dependent. */ scrollbarInset?: number; class?: string;}const GRADIENT_DIRECTION: Record<ProgressiveBlurSide, string> = { bottom: "to top", top: "to bottom", left: "to right", right: "to left",};// CSS can't animate a single element's blur amount across a gradient, so this stacks `layers` divs, each blurred more and masked to a shrinking region.// Props are read once in `render()`, not reactively — remount this component when `scrollbarInset` (or any prop) needs to change.@Component()export class ProgressiveBlur extends StatelessComponent<ProgressiveBlurProps> { @Styled(ProgressiveBlurStyles) $s!: ProgressiveBlurStyles; render() { const { side = "bottom", size = 96, layers = 6, maxBlur = 16, scrollbarInset = 0, class: cls } = this.props; const isVertical = side === "top" || side === "bottom"; const direction = GRADIENT_DIRECTION[side]; const sideClass = { bottom: this.$s.$bottom, top: this.$s.$top, left: this.$s.$left, right: this.$s.$right }[side]; const sizeStyle = isVertical ? { height: `${String(size)}px` } : { width: `${String(size)}px` }; const crossInsetStyle = scrollbarInset > 0 ? (isVertical ? { right: `${String(scrollbarInset)}px` } : { bottom: `${String(scrollbarInset)}px` }) : undefined; return ( <div aria-hidden class={cx(this.$s.$root, sideClass, cls)} style={{ ...sizeStyle, ...crossInsetStyle }}> {Array.from({ length: layers }, (_, i) => { const blur = maxBlur * ((i + 1) / layers); const start = (i / layers) * 100; const end = ((i + 1) / layers) * 100; const mask = `linear-gradient(${direction}, transparent ${String(start)}%, black ${String(end)}%, black 100%)`; return ( <div key={i} class={this.$s.$layer} style={{ backdropFilter: `blur(${String(blur)}px)`, maskImage: mask, }} /> ); })} </div> ); }}Examples
About
Purely presentational — no Morphos equivalent. CSS can't animate a single element's blur amount
across a gradient, so this stacks layers absolutely-positioned divs, each blurred more than the
last and masked to a shrinking region, to approximate one. Absolutely positioned — place it inside
a relative container over the edge you want faded. The Static example above is the simplest
case: a plain "read more" teaser fade over content that doesn't scroll at all, no scrollbar to
think about.
Once the content it fades does scroll (the Default example above), use two nested
containers, not one: an outer relative + fixed-height + overflow-hidden wrapper, with the
actual scrollable content as an overflow-y-auto child, and ProgressiveBlur as that child's
sibling (not its child). If ProgressiveBlur were placed inside the scrolling element instead,
its bottom-0 would anchor to the bottom of the full scrollable content (which can be far
taller than what's visible) rather than to the visible viewport's bottom edge — so it would only
appear once scrolled all the way down, the opposite of the always-visible fade you actually want.
ProgressiveBlur doesn't know anything about scrollbars, and by default it spans the full width of
its containing block — including whatever column a vertical scrollbar reserves inside the sibling
scroller, which visibly blurs/washes out the scrollbar itself. Two ways to deal with that, shown in
the Scrollable, No Scrollbar and Default examples above:
- Hide the scrollbar (
scrollbar-width: none+::-webkit-scrollbar { display: none }— Tailwind's built-inscrollbar-noneutility does both) if you don't need a visible one. The element stays fully scrollable by wheel/touch/keyboard; there's just no thumb forProgressiveBlurto collide with, and no measuring needed. - Keep the scrollbar and measure it, via
scrollbarInset(px), if you want it visible. Don't guess a fixed number — scrollbar width isn't a constant, it's0with macOS's overlay-style scrollbars and a different value per OS/browser/zoom level otherwise — measure the real value off the scroller itself (el.offsetWidth - el.clientWidth) and pass that.ProgressiveBluronly readsscrollbarInsetonce, in its ownrender()(like every other prop), so if you're measuring it after mount — typically the case, since the scrollbar's real width isn't known until the scroller has laid out — mountProgressiveBluritself from inside a reactive thunk keyed off that measured@Statevalue, so it re-creates with the up-to-date number (see the Default story's source for the fullResizeObserver-based measuring pattern, which also keeps it correct across container resizes).
Usage
import { ProgressiveBlur } from "@/components/ui/progressive-blur";
// Static content — no scrollbar involved.
<div class="relative h-40 overflow-hidden rounded-lg border p-4">
<p>{/* teaser text */}</p>
<ProgressiveBlur side="bottom" size={64} />
</div>
// Scrollable content, scrollbar hidden — no measuring needed.
<div class="relative h-64 overflow-hidden rounded-lg border">
<div class="h-full overflow-y-auto scrollbar-none p-3">
{/* scrollable content */}
</div>
<ProgressiveBlur side="bottom" size={80} />
</div>
// Scrollable content, scrollbar visible — measure its real width and pass it along.
<div class="relative h-64 overflow-hidden rounded-lg border">
<div class="h-full overflow-y-auto p-3">
{/* scrollable content */}
</div>
<ProgressiveBlur side="bottom" size={80} scrollbarInset={measuredScrollbarWidth} />
</div>Props
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" |
size | number (px depth of the blurred region) | 96 |
layers | number | 6 |
maxBlur | number (px, at the strongest layer) | 16 |
scrollbarInset | number (px, measured scrollbar width) | 0 |
class | string | — |