Skip to content

Components / UI

Liquid Glass

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.

Live demo

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.

Measured

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

Limited availability

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).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/liquid-glass.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

LiquidGlass.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
strengthnumber40Displacement at the rim in px (Chromium). 0 leaves the refraction layer out entirely.
edgenumber0.18How far the bend reaches in from each edge, as a fraction of the box (0.02–0.5).
blurstring'10px'Backdrop blur radius.
tintnumbernoneOpacity 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).
radiusstring'1.5rem'Corner radius; the rim and the refraction follow it.
idstringunique per paneThe filter id. Omitted, every pane gets its own, so ids never repeat on a page.

Reduced motion

No sheen; the glass, the rim and the refraction are static and stay.

With ClientRouter

Static; nothing to bind. The inline SVG filter is part of the page and swaps with it.

Why it is built this way

Replaces: LiquidGlass (React Bits) · Glass card (Kokonut UI) · liquid-glass-react. See the migration table.

Progressive Blur

Backgrounds

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.

every browseralways

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Tilt

Pointer

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.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown