Liquid Glass
Reusable refraction glass for components/grid-wallet-demo. Full docs:
components/grid-wallet-demo/src/components/liquid-glass/README.md — read it
before implementing anything non-trivial.
The rule that prevents 90% of the pain
The glass refracts its own CHILDREN, not the page behind it. There is no
"frost whatever is behind me" mode (backdrop-filter can't refract portably). To
bend a background you put a copy of it inside the glass. So:
- Glass over a known backdrop (color / gradient / image / pattern) → use
GlassOver, which makes the copy for you.
- Glass over live UI behind a small surface (over a static screen) →
achievable: feed the glass a positioned copy of that UI. See "Refract live
UI behind a small surface" in the gotchas / README.
- Glass over arbitrary scrolling/animating DOM → not achievable. Tell the user.
Exception: a big frosted surface (sheet/modal) should NOT refract — the
per-frame SVG filter tanks Safari. Use FrostPanel (GPU backdrop-filter + a
specular edge). Refraction is for the small, tactile elements.
Skipping this gives a flat panel that doesn't lens (the classic failure).
Pick the right entry point
| Need |
Use |
| Glass button / tab bar / card / chip over a known bg |
GlassOver (easiest) |
| Refract content you already render, or custom backdrop wiring |
Glass (= LiquidGlass) |
| A big frosted sheet/modal (no refraction — fast on Safari) |
FrostPanel (BottomSheet uses it) |
| A small surface that refracts the (static) screen behind it |
GlassOver's backdropNode |
| A lens over the LIVE aurora that must work on Safari |
WebGL: AuroraLensButton (circle) / AuroraLensPanel (rect squircle) |
| The phone preview's WebGL stage |
glass-gl/StageGL — bespoke, don't reuse |
SVG vs shader (which renderer): default to the SVG path (Glass /
GlassOver) for anything made of live DOM — buttons, tab bars, cards, text,
layouts (keeps content selectable/clickable). The shader / WebGL path is only
needed when you're refracting content that is not live DOM — a <canvas>
drawing (e.g. a generated QR) or a <video> (Safari won't SVG-filter those).
There is no auto-switching and no general "glass over a canvas/video" yet:
the only WebGL renderer (StageGL) is bespoke to the phone and re-derives the
math in GLSL. If a canvas/video glass actually comes up, generalize StageGL
(or build a map-fed WebGL glass) on demand — don't assume a drop-in exists.
Minimal usage
import { GlassOver } from '@/components/liquid-glass';
const BG = 'linear-gradient(135deg, #5b9dff, #b07cff)';
<div style={{ background: BG }}>
<GlassOver backdrop={BG} radius={16} depth={10} scale={18} cornerSmoothing={0.6}>
<span style={{ display: 'block', padding: '12px 22px', color: '#fff', fontWeight: 600 }}>
Settings
</span>
</GlassOver>
</div>
backdrop = the same CSS background the surface sits on (gets refracted).
children = content on top (not refracted). Any GlassConfig field is a prop.
Start from a preset (DEFAULT_GLASS, PHONE_SHELL_GLASS) and override. Frosted
surfaces (FrostPanel) use the slimmer FrostConfig, not GlassConfig.
Must-know gotchas (details in the README)
- Hover/resize: animate
transform: scale(), never width/height/radius — the
displacement map re-bakes on shape change (jank).
- Refraction needs texture: over a flat color you only see specular/edge rim.
- Tiny elements: lower
depth/scale or they over-distort.
specularRotation is directional (45° puts a bright highlight on the
top-left/bottom-right corners) — rotate or lower edgeStrength to move it.
corner-shape: squircle is Chromium-only (graceful circular fallback).
- Outer drop shadow → round the wrapper. A glass surface clips itself with
overflow: hidden, so an outer box-shadow has to live on a wrapper around
the glass (inside, the clip eats it). box-shadow traces that wrapper's own
border-radius/corner-shape, not the glass child's — so an unrounded wrapper
gives a square halo around round glass. Give the shadow wrapper the same
border-radius + corner-shape as the glass (see GlassSymbolButton /
GlassWindowButtonGroup .root). Same rule for any border/background painted
on a wrapper. Debug heuristic: a square around glass = a rounding-unaware layer.
- Tiled backgrounds: pass
GlassOver's backdropOffset={{x,y}} to align.
- Circles & pills auto-lens radially (Aave's production circle/pill model,
detected from the geometry — no flag).
splay doesn't apply to them; the dome
applies to circles only. Don't fight it with per-axis expectations.
- Live/changing content inside a lens is a Safari minefield — stale
filter-output caching, composited descendants escaping the lens unfiltered
(
translateZ(0)/will-change/motion's filter: blur(0px) leftovers),
canvas/video killing the filter, and a LAYOUT-bounds source-size ceiling. Read
"Safari field notes" in the README before attempting; the full experiment is
parked on pat/scroll-refraction-experiment.
- WebKit renders SVG displacement over a copied subtree as EMPTY output at
notification size (confirmed June 2026 — see the README's "Confirmed dead
end" postmortem). Don't retry it: use the WebGL lenses for Safari refraction
and gate the SVG path to Chromium. Squircle-rect corner rules for Safari
(map smoothing with
cornerRadii, overlay border-radius intersection,
one-element frost, no drop-shadow wrappers) are in the same section.
- Verifying a bend over a soft backdrop: displacement is invisible without
texture — draw a sharp debug grid through the bent coordinates to check.
- Big sheets/modals don't refract — they frost. A large displacement lens
re-runs its whole SVG filter every frame while the surface animates, which tanks
Safari (CPU
feDisplacementMap). Use FrostPanel (GPU backdrop-filter + a
static specular edge); keep refractive Glass/GlassOver for the small buttons
on top. Example: apps/shared/BottomSheet (FrostPanel) → aurora/PasskeySheet.
- Refract live UI behind a small surface: pass a copy of the behind-UI as
GlassOver's backdropNode, anchored to align, and counter-animate it
against any slide so it stays put while the glass slides over it. Behind-UI must
be static while open. (Not for big sheets — see above.)
When tuning
There's a live tuning panel: run the demo (npm run dev, port 4000), open the
App panel, switch it to swag mode. Sliders map 1:1 to GlassConfig.
1---2name: liquid-glass3description: Build and reuse the "liquid glass" refraction UI in components/grid-wallet-demo (the LiquidGlass / Glass / GlassOver components). Use this skill when the user asks for a glass / liquid-glass / frosted / refraction / squircle UI element — e.g. "make a glass button", "glass tab bar", "glass card", "glass panel", "glass nav", "make this glass", "glass border that expands on hover", "frosted surface", or to tune the glass effect (refraction, chroma, specular, blur, corner smoothing, shadow).4---56# Liquid Glass78Reusable refraction glass for `components/grid-wallet-demo`. Full docs:9**`components/grid-wallet-demo/src/components/liquid-glass/README.md`** — read it10before implementing anything non-trivial.1112## The rule that prevents 90% of the pain1314**The glass refracts its own CHILDREN, not the page behind it.** There is no15"frost whatever is behind me" mode (`backdrop-filter` can't refract portably). To16bend a background you put a *copy* of it inside the glass. So:1718- Glass over a **known** backdrop (color / gradient / image / pattern) → use19 `GlassOver`, which makes the copy for you.20- Glass over **live UI behind a small surface** (over a *static* screen) →21 achievable: feed the glass a positioned **copy** of that UI. See "Refract live22 UI behind a small surface" in the gotchas / README.23- Glass over **arbitrary scrolling/animating DOM** → not achievable. Tell the user.2425Exception: a **big** frosted surface (sheet/modal) should NOT refract — the26per-frame SVG filter tanks Safari. Use `FrostPanel` (GPU `backdrop-filter` + a27specular edge). Refraction is for the small, tactile elements.2829Skipping this gives a flat panel that doesn't lens (the classic failure).3031## Pick the right entry point3233| Need | Use |34|---|---|35| Glass button / tab bar / card / chip over a known bg | `GlassOver` (easiest) |36| Refract content you already render, or custom backdrop wiring | `Glass` (= `LiquidGlass`) |37| A big frosted sheet/modal (no refraction — fast on Safari) | `FrostPanel` (`BottomSheet` uses it) |38| A small surface that refracts the (static) screen behind it | `GlassOver`'s `backdropNode` |39| A lens over the LIVE aurora that must work on Safari | WebGL: `AuroraLensButton` (circle) / `AuroraLensPanel` (rect squircle) |40| The phone preview's WebGL stage | `glass-gl/StageGL` — **bespoke, don't reuse** |4142**SVG vs shader (which renderer):** **default to the SVG path** (`Glass` /43`GlassOver`) for anything made of **live DOM** — buttons, tab bars, cards, text,44layouts (keeps content selectable/clickable). The **shader / WebGL** path is only45needed when you're refracting content that is **not** live DOM — a `<canvas>`46drawing (e.g. a generated QR) or a `<video>` (Safari won't SVG-filter those).47There is **no auto-switching** and no general "glass over a canvas/video" yet:48the only WebGL renderer (`StageGL`) is bespoke to the phone and re-derives the49math in GLSL. If a canvas/video glass actually comes up, **generalize `StageGL`50(or build a map-fed WebGL glass) on demand** — don't assume a drop-in exists.5152## Minimal usage5354```tsx55import { GlassOver } from '@/components/liquid-glass';5657const BG = 'linear-gradient(135deg, #5b9dff, #b07cff)';5859<div style={{ background: BG }}>60 <GlassOver backdrop={BG} radius={16} depth={10} scale={18} cornerSmoothing={0.6}>61 <span style={{ display: 'block', padding: '12px 22px', color: '#fff', fontWeight: 600 }}>62 Settings63 </span>64 </GlassOver>65</div>66```6768`backdrop` = the same CSS background the surface sits on (gets refracted).69`children` = content on top (not refracted). Any `GlassConfig` field is a prop.70Start from a preset (`DEFAULT_GLASS`, `PHONE_SHELL_GLASS`) and override. Frosted71surfaces (`FrostPanel`) use the slimmer `FrostConfig`, not `GlassConfig`.7273## Must-know gotchas (details in the README)7475- **Hover/resize:** animate `transform: scale()`, never width/height/radius — the76 displacement map re-bakes on shape change (jank).77- **Refraction needs texture:** over a flat color you only see specular/edge rim.78- **Tiny elements:** lower `depth`/`scale` or they over-distort.79- **`specularRotation`** is directional (45° puts a bright highlight on the80 top-left/bottom-right corners) — rotate or lower `edgeStrength` to move it.81- **`corner-shape: squircle`** is Chromium-only (graceful circular fallback).82- **Outer drop shadow → round the wrapper.** A glass surface clips itself with83 `overflow: hidden`, so an outer `box-shadow` has to live on a wrapper *around*84 the glass (inside, the clip eats it). `box-shadow` traces that wrapper's *own*85 `border-radius`/`corner-shape`, not the glass child's — so an unrounded wrapper86 gives a **square halo around round glass**. Give the shadow wrapper the same87 `border-radius` + `corner-shape` as the glass (see `GlassSymbolButton` /88 `GlassWindowButtonGroup` `.root`). Same rule for any border/background painted89 on a wrapper. Debug heuristic: a square around glass = a rounding-unaware layer.90- **Tiled backgrounds:** pass `GlassOver`'s `backdropOffset={{x,y}}` to align.91- **Circles & pills auto-lens radially** (Aave's production circle/pill model,92 detected from the geometry — no flag). `splay` doesn't apply to them; the dome93 applies to circles only. Don't fight it with per-axis expectations.94- **Live/changing content inside a lens is a Safari minefield** — stale95 filter-output caching, composited descendants escaping the lens unfiltered96 (`translateZ(0)`/`will-change`/motion's `filter: blur(0px)` leftovers),97 canvas/video killing the filter, and a LAYOUT-bounds source-size ceiling. Read98 "Safari field notes" in the README before attempting; the full experiment is99 parked on `pat/scroll-refraction-experiment`.100- **WebKit renders SVG displacement over a copied subtree as EMPTY output** at101 notification size (confirmed June 2026 — see the README's "Confirmed dead102 end" postmortem). Don't retry it: use the WebGL lenses for Safari refraction103 and gate the SVG path to Chromium. Squircle-rect corner rules for Safari104 (map smoothing with `cornerRadii`, overlay border-radius intersection,105 one-element frost, no drop-shadow wrappers) are in the same section.106- **Verifying a bend over a soft backdrop:** displacement is invisible without107 texture — draw a sharp debug grid through the bent coordinates to check.108- **Big sheets/modals don't refract — they frost.** A large displacement lens109 re-runs its whole SVG filter every frame while the surface animates, which tanks110 Safari (CPU `feDisplacementMap`). Use `FrostPanel` (GPU `backdrop-filter` + a111 static specular edge); keep refractive `Glass`/`GlassOver` for the small buttons112 on top. Example: `apps/shared/BottomSheet` (FrostPanel) → `aurora/PasskeySheet`.113- **Refract live UI behind a *small* surface:** pass a *copy* of the behind-UI as114 `GlassOver`'s `backdropNode`, anchored to align, and **counter-animate it**115 against any slide so it stays put while the glass slides over it. Behind-UI must116 be static while open. (Not for big sheets — see above.)117118## When tuning119120There's a live tuning panel: run the demo (`npm run dev`, port 4000), open the121App panel, switch it to **swag** mode. Sliders map 1:1 to `GlassConfig`.