Progressive Blur
BackgroundsAn edge that blurs what scrolls under it, more the closer it gets: a few bands, each with a backdrop blur twice the last and a mask that keeps it to its own slice, so the blur ramps instead of stepping.
Components / UI
A glass pane that blurs and tints whatever is behind it. Every engine: backdrop blur and saturation, a specular rim, a tint from the tokens, a sheen that crosses on hover. The refraction at the rim is an SVG displacement map on its own backdrop-filter layer, built on the server from strength and edge and stretched to the box by the filter, so it works at any size with no measuring. Chromium bends the backdrop; Firefox and WebKit paint nothing on that layer and show the same glass without the bend. Zero JavaScript.
glass
The edge bends the scene.
Blur, saturation and a rim in every engine; the refraction where the engine paints it.
hover · sheen crosses
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 5.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 76 · not Firefox · not Safari; needs backdrop-filter, backdrop-svg. Elsewhere: Firefox and Safari show the glass (blur, saturation, rim, tint, sheen) without the rim refraction; the displacement layer paints nothing there (measured, 0 pixels differ).
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import LiquidGlass from '@moonarc/core/LiquidGlass';
---
<div class="relative">
<img src={photo.src} alt="" class="w-full" />
<LiquidGlass class="absolute inset-x-8 bottom-8 p-6" strength={40} radius="2rem">
<h3>Glass over the photo</h3>
<p>Blur, a rim and a sheen on hover. In Chromium the edge also bends the picture.</p>
</LiquidGlass>
</div>
<!-- a pill, no refraction -->
<LiquidGlass strength={0} radius="999px" class="px-5 py-2">Pill</LiquidGlass>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/liquid-glass.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* LiquidGlass — a pane of glass over whatever is behind it, zero JS. In
* every engine: `backdrop-filter: blur() saturate()`, a specular rim from
* two inset highlights, a soft tint from the tokens, and a sheen that
* slides across on :hover / :focus-within (CSS, so the trigger is hover,
* not the pointer's position). The refraction — the backdrop bending at
* the rim — is an SVG displacement map on a pseudo-element with
* `backdrop-filter: url(#…)`: the map is built on the server from
* `strength` and `edge` as two gradients (red bends x, green bends y)
* composited in the filter, stretched to the element's box by the filter
* itself, so no size is ever measured and any size works. Chromium renders
* it; Firefox and WebKit accept the declaration and paint nothing on that
* layer (measured), so they show the same glass without the bend.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Displacement at the rim, in px (Chromium). 0 removes the layer. */
strength?: number;
/** How far the bend reaches in from the edge, as a fraction of the box. */
edge?: number;
/** Backdrop blur radius, any CSS length. */
blur?: string;
/** Tint opacity of the pane, 0–1; default leans on the theme. */
tint?: number;
/** Corner radius, any CSS length. */
radius?: string;
/** Filter id; unique per pane when omitted. */
id?: string;
}
const { strength = 40, edge = 0.18, blur = '10px', tint, radius = '1.5rem', id, class: className, style, ...rest } = Astro.props;
const e = Math.min(0.5, Math.max(0.02, edge));
// one filter per pane: two panes with the same props used to render two filters under one id, which a page may not have
const filterId = id ?? `ma-glass-${Math.round(strength)}-${Math.round(e * 100)}-${Math.random().toString(36).slice(2, 7)}`;
// one axis of the map: neutral (128) through the middle, a smooth ramp to 0 and 255 across the edge band
const ramp = (axis: 'x' | 'y') => {
const c = (v: number) => (axis === 'x' ? `rgb(${v},128,128)` : `rgb(128,${v},128)`);
// an ease across the band (four stops a side), so the bend fades in with no seam where the map goes neutral
const stops = [
[0, 0],
[e * 0.25, 48],
[e * 0.55, 96],
[e * 0.8, 118],
[e, 128],
[1 - e, 128],
[1 - e * 0.8, 138],
[1 - e * 0.55, 160],
[1 - e * 0.25, 208],
[1, 255],
]
.map(([o, v]) => `<stop offset='${o}' stop-color='${c(v!)}'/>`)
.join('');
const dir = axis === 'x' ? "x1='0' x2='1' y1='0' y2='0'" : "x1='0' x2='0' y1='0' y2='1'";
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'><linearGradient id='g' ${dir}>${stops}</linearGradient><rect width='100' height='100' fill='url(#g)'/></svg>`)}`;
};
const vars = [`--ma-glass-blur:${blur}`, `--ma-glass-r:${radius}`, tint !== undefined && `--ma-glass-tint:${tint}`, `--ma-glass-filter:url(#${filterId})`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-glass', className]} data-refract={strength > 0 ? '' : undefined} style={vars} {...rest}>
{strength > 0 && (
<svg class="ma-glass__map" width="0" height="0" aria-hidden="true" focusable="false">
<filter id={filterId} x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feImage href={ramp('x')} result="mx" preserveAspectRatio="none" x="0" y="0" width="100%" height="100%" />
<feImage href={ramp('y')} result="my" preserveAspectRatio="none" x="0" y="0" width="100%" height="100%" />
<feComposite in="mx" in2="my" operator="arithmetic" k1="0" k2="0.5" k3="0.5" k4="0" result="map" />
<feDisplacementMap in="SourceGraphic" in2="map" scale={strength * 2} xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
)}
<div class="ma-glass__body"><slot /></div>
</div>
<style is:global>
@layer components {
:where(.ma-glass) {
/* the tint leans lighter on a light ground, darker on a dark one, from the theme scalar */
--ma-glass-tint: calc(0.22 - var(--ma-dark) * 0.1);
position: relative;
isolation: isolate;
border-radius: var(--ma-glass-r, 1.5rem);
background: color-mix(in srgb, var(--ma-panel) calc(var(--ma-glass-tint) * 100%), transparent);
color: inherit;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ma-shine) 55%, transparent),
inset 0 1px 0 var(--ma-shine),
inset 0 -1px 0 color-mix(in srgb, var(--ma-shine) 25%, transparent),
0 24px 48px -24px rgb(0 0 0 / 0.45);
backdrop-filter: blur(var(--ma-glass-blur, 10px)) saturate(1.5);
overflow: clip;
}
/* the rim refraction: its own layer, so an engine that paints nothing for an SVG backdrop filter still shows the glass */
:where(.ma-glass[data-refract])::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
backdrop-filter: var(--ma-glass-filter);
pointer-events: none;
}
/* the sheen: a diagonal band parked off the left edge, crossing on hover */
:where(.ma-glass)::after {
content: '';
position: absolute;
inset: -20% -60%;
z-index: -1;
background: linear-gradient(115deg, transparent 40%, color-mix(in srgb, var(--ma-shine) 60%, transparent) 50%, transparent 60%);
translate: -70% 0;
opacity: 0;
pointer-events: none;
transition:
translate var(--ma-duration-slow) var(--ma-ease-out),
opacity var(--ma-duration-fast) var(--ma-ease-out);
}
@media (hover: hover) and (pointer: fine) {
:where(.ma-glass:hover)::after,
:where(.ma-glass:focus-within)::after {
translate: 70% 0;
opacity: 1;
transition:
translate var(--ma-duration-slow) var(--ma-ease-in-out),
opacity var(--ma-duration-fast) var(--ma-ease-out);
}
}
:where(.ma-glass__map) {
position: absolute;
}
:where(.ma-glass__body) {
position: relative;
z-index: 1;
}
@media (forced-colors: active) {
:where(.ma-glass) {
border: 1px solid CanvasText;
background: Canvas;
backdrop-filter: none;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-glass)::after {
transition: none;
display: none;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
strength | number | 40 | Displacement at the rim in px (Chromium). 0 leaves the refraction layer out entirely. |
edge | number | 0.18 | How far the bend reaches in from each edge, as a fraction of the box (0.02–0.5). |
blur | string | '10px' | Backdrop blur radius. |
tint | number | none | Opacity of the panel tint, 0–1; by default 0.22 on a light ground and 0.12 on a dark one (the --ma-dark scalar). |
radius | string | '1.5rem' | Corner radius; the rim and the refraction follow it. |
id | string | unique per pane | The filter id. Omitted, every pane gets its own, so ids never repeat on a page. |
No sheen; the glass, the rim and the refraction are static and stay.
ClientRouterStatic; nothing to bind. The inline SVG filter is part of the page and swaps with it.
Replaces: LiquidGlass (React Bits) · Glass card (Kokonut UI) · liquid-glass-react. See the migration table.
An edge that blurs what scrolls under it, more the closer it gets: a few bands, each with a backdrop blur twice the last and a mask that keeps it to its own slice, so the blur ramps instead of stepping.
A soft radial light follows the cursor over a card and fades in only while hovered.
A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown