Kosmesis
Components

Progressive Blur

A stacked-layer approximation of an iOS-style progressive blur edge. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add progressive-blur
pnpm dlx kosmesis add progressive-blur
yarn dlx kosmesis add progressive-blur
bunx kosmesis add progressive-blur

Copy and paste the following code into your project.

progressive-blur.tsx
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/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

progressive-blur.tsx
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-in scrollbar-none utility does both) if you don't need a visible one. The element stays fully scrollable by wheel/touch/keyboard; there's just no thumb for ProgressiveBlur to 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's 0 with 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. ProgressiveBlur only reads scrollbarInset once, in its own render() (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 — mount ProgressiveBlur itself from inside a reactive thunk keyed off that measured @State value, so it re-creates with the up-to-date number (see the Default story's source for the full ResizeObserver-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

PropTypeDefault
side"top" | "bottom" | "left" | "right""bottom"
sizenumber (px depth of the blurred region)96
layersnumber6
maxBlurnumber (px, at the strongest layer)16
scrollbarInsetnumber (px, measured scrollbar width)0
classstring

On this page