# Liquid Glass (Moonarc)

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.

- Import: `import LiquidGlass from '@moonarc/core/LiquidGlass'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/liquid-glass.json`
- Tier A · category ui · trigger hover
- Readout: `<LiquidGlass strength={40} blur="10px">`
- Browser support: limited (Chrome 76 · not Firefox · not Safari); 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)
- Measured cost: 0 B JS (CSS 5.6 kB raw)
- Page: https://moonarc.dev/components/liquid-glass/

## Props

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

## Usage

```astro
<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>
```

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

## Craft

- The refraction was prototyped first, as the handoff asked (docs/phase-7e.md): backdrop-filter: url(#filter) with feDisplacementMap renders in Chromium only. Firefox 155 and WebKit 26.6 accept the declaration in computed style and paint nothing. This was measured on a probe page with the layer on and off: 0 pixels differ. So the refraction lives on a pseudo-element of its own, and the glass every engine sees is the element's own backdrop-filter, never invalidated by a url() it cannot paint.
- The displacement map is two gradients, red for x and green for y, neutral through the middle and ramping across the edge band, composited to one map inside the filter (feComposite arithmetic) and stretched to the box by feImage at 100 %, so a pill, a card and a full-width bar all get a rim bend with the same map and nothing measured in the DOM.
- The rim is three inset shadows in the shine token (a hairline all round, a bright top edge, a fainter bottom one), so the pane has a thickness. The tint leans on --ma-dark: a whiter pane on a light page, a darker one on a dark page, from one calc().
- The sheen is a diagonal band parked off the left edge that crosses on :hover and :focus-within, on the slow duration with the in-out curve; behind (hover: hover), so on touch nothing sticks.
- Text on the pane should be the full foreground colour: measured on the stage over a bright scene, the site's muted text token fell to 2.4 : 1 while the foreground held 6 : 1 and above in both themes. The tint is too faint to act as a surface for text.
- Windows high contrast drops the filters and draws a Canvas panel with a CanvasText border, which stays legible.

## Replaces

- LiquidGlass (React Bits)
- Glass card (Kokonut UI)
- liquid-glass-react

## Source

```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>

```
