@elemeta/glasswave
v0.7.2
Published
GPU-accelerated liquid glass and morphing surfaces for Vue, React, and Next.js, powered by WebGL2
Maintainers
Readme
Glasswave
GPU-accelerated liquid glass and morphing surfaces for Vue, React, and Next.js, powered by WebGL2.
Glasswave renders every glass surface inside a LiquidBackdrop through one shared GPU scene. Your content stays in the DOM, while refraction, depth, dispersion, frost, and optional SDF morphing are rendered against the backdrop.
Highlights
- One shared WebGL2 canvas per backdrop, with an instanced fast path and ordered composition only for overlapping lenses.
- Typed Vue, React, and Next.js adapters with the same material contract.
- Dynamic URL, image, video, canvas, and ImageBitmap backdrop sources with frame-aware texture uploads.
- Smooth SDF morphing between nearby glass surfaces.
- Cached geometry, viewport culling, dirty-state scheduling, and event-driven rendering.
- DOM content, focus, pointer input, and accessibility remain outside the canvas.
- Runtime metrics and context-loss lifecycle events for production diagnostics.
Install
npm install @elemeta/glasswaveOr with Bun:
bun add @elemeta/glasswaveInstall the peer dependencies for the framework used by your application:
# Vue
npm install vue
# React or Next.js
npm install react react-domSupported peers:
- Vue 3.5 or newer
- React 18.3 or React 19
- Next.js through the React client entry
How it works
Glasswave uses two primary primitives:
LiquidBackdropowns the background image or color and one shared WebGL2 canvas.LiquidGlassregisters one lens inside that backdrop.
LiquidBackdrop
|-- backdrop image or color
|-- shared WebGL2 canvas
|-- LiquidGlass
|-- LiquidGlass
`-- LiquidGlassUse one backdrop for all lenses that sample the same visual source. Do not create a separate backdrop for every button.
Material API
Every lens accepts a typed glassEffect object with seven primary controls:
import type { LiquidGlassEffect } from '@elemeta/glasswave'
const effect: LiquidGlassEffect = {
lightAngle: -45,
lightIntensity: 0.3,
refraction: 80,
depth: 20,
dispersion: 0.5,
frost: 4,
splay: 0,
}| Parameter | Range or unit | Description |
| --- | --- | --- |
| lightAngle | degrees | Direction of the edge light. |
| lightIntensity | 0...1 | Strength of the edge light. 0.3 means 30%. |
| refraction | 0...100 | Edge-refraction strength. 80 means 80%. |
| depth | CSS pixels, >= 0 | Width of the curved refracting band inside the edge. |
| dispersion | 0...1 | Separation of red, green, and blue light. |
| frost | CSS pixels, >= 0 | Blur applied to the sampled backdrop. |
| splay | 0...1 | Angular spread of the directional highlight. |
lightIntensity: 0.3 controls the edge light, not the opacity of the complete lens. refraction: 80 uses Glasswave's 0...100 optical control scale. Use color and opacity for the material body.
The values above are a balanced starting profile. Every field is optional.
Recommended tuning order
- Match
lightAngleto the scene. - Set
lightIntensityfor edge visibility. - Increase
refractionuntil the bend is readable. - Adjust
depthuntil the refracting edge is proportional to the lens. - Add
frostonly when softness is needed. - Add restrained
dispersionif chromatic separation benefits the scene. - Finish with
splay.
Vue
<script setup lang="ts">
import type { LiquidGlassEffect } from '@elemeta/glasswave'
import { LiquidBackdrop, LiquidGlass } from '@elemeta/glasswave/vue'
import '@elemeta/glasswave/style.css'
const effect: LiquidGlassEffect = {
lightAngle: -45,
lightIntensity: 0.3,
refraction: 80,
depth: 20,
dispersion: 0.2,
frost: 4,
splay: 0,
}
</script>
<template>
<LiquidBackdrop
class="glass-scene"
src="/wallpaper.webp"
alt=""
fit="cover"
position="50% 50%"
>
<LiquidGlass
class="glass-panel"
:glass-effect="effect"
:radius="36"
color="#1A1A26"
:opacity="0.5"
>
Liquid glass
</LiquidGlass>
</LiquidBackdrop>
</template>
<style scoped>
.glass-scene {
position: relative;
width: min(720px, 100%);
min-height: 420px;
overflow: hidden;
}
.glass-panel {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: 320px;
min-height: 120px;
place-items: center;
transform: translate(-50%, -50%);
}
</style>React
import type { LiquidGlassEffect } from '@elemeta/glasswave'
import { LiquidBackdrop, LiquidGlass } from '@elemeta/glasswave/react'
import '@elemeta/glasswave/style.css'
import './glass-example.css'
const effect: LiquidGlassEffect = {
lightAngle: -45,
lightIntensity: 0.3,
refraction: 80,
depth: 20,
dispersion: 0.2,
frost: 4,
splay: 0,
}
export function GlassExample() {
return (
<LiquidBackdrop
className="glass-scene"
src="/wallpaper.webp"
alt=""
fit="cover"
position="50% 50%"
>
<LiquidGlass
className="glass-panel"
glassEffect={effect}
radius={36}
color="#1A1A26"
opacity={0.5}
>
Liquid glass
</LiquidGlass>
</LiquidBackdrop>
)
}.glass-scene {
position: relative;
width: min(720px, 100%);
min-height: 420px;
overflow: hidden;
}
.glass-panel {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: 320px;
min-height: 120px;
place-items: center;
transform: translate(-50%, -50%);
}Next.js App Router
Import the client components from @elemeta/glasswave/next.
'use client'
import type { LiquidGlassEffect } from '@elemeta/glasswave'
import { LiquidBackdrop, LiquidGlass } from '@elemeta/glasswave/next'
const effect: LiquidGlassEffect = {
lightAngle: -45,
lightIntensity: 0.3,
refraction: 80,
depth: 20,
dispersion: 0.2,
frost: 4,
splay: 0,
}
export function GlassCard() {
return (
<LiquidBackdrop className="glass-scene" src="/wallpaper.webp" alt="">
<LiquidGlass
className="glass-panel"
color="#1A1A26"
opacity={0.5}
glassEffect={effect}
radius={36}
>
Liquid glass
</LiquidGlass>
</LiquidBackdrop>
)
}Load the shared stylesheet from the root layout or the application's global stylesheet:
import '@elemeta/glasswave/style.css'WebGL initialization and geometry measurement begin after mount, so package imports remain SSR-safe.
Multiple lenses
All descendant lenses share the backdrop canvas. Non-overlapping lenses use the instanced fast path; intersecting lenses are composited in layer order:
<LiquidBackdrop class="scene" src="/wallpaper.webp" alt="">
<LiquidGlass class="card card-a" :glass-effect="effect">A</LiquidGlass>
<LiquidGlass class="card card-b" :glass-effect="effect">B</LiquidGlass>
<LiquidGlass class="card card-c" :glass-effect="effect">C</LiquidGlass>
</LiquidBackdrop>Create another LiquidBackdrop only when a region requires a different source image, color, fit, position, or independent coordinate system.
Surface material and overlapping layers
Use color and opacity for the body of the material. Glasswave renders one cross-platform GPU surface highlight; ordinary, layered, and morphing lenses use the same edge-light shader while WebGL-unavailable environments retain the DOM fallback. highlightStyle accepts only surface.
<LiquidGlass
color="#1A1A26"
opacity={0.5}
radius={36}
layer={2}
glassEffect={effect}
>
Content
</LiquidGlass>When lenses overlap inside the same LiquidBackdrop, the upper lens samples the completed lower material. layer controls GPU order and the default DOM z-index. Pulling the lenses apart returns them to the instanced fast path.
Do not add an opaque CSS background, backdrop-filter, or root CSS opacity to LiquidGlass; those declarations cover or bypass the GPU material. Your CSS should define size, placement, and content layout only.
See Material and layering for complete React, Vue, Next.js, theme, migration, and glass-over-glass examples.
Dynamic backdrop sources
Use the source prop when the backdrop can change at runtime. It accepts a URL string, HTMLImageElement, HTMLVideoElement, HTMLCanvasElement, or ImageBitmap and takes precedence over the legacy src prop.
import type { GlasswaveBackdropSource } from '@elemeta/glasswave'React and Next.js:
<LiquidBackdrop source={activeSource} alt="" fit="cover">
<LiquidGlass className="glass" glassEffect={effect} />
</LiquidBackdrop>Vue:
<LiquidBackdrop :source="activeSource" alt="" fit="cover">
<LiquidGlass class="glass" :glass-effect="effect" />
</LiquidBackdrop>Source update behavior:
- URL strings and
HTMLImageElementsources upload after decoded pixels are ready. HTMLVideoElementusesrequestVideoFrameCallbackwhen available, so the GPU texture changes only for newly decoded video frames.HTMLCanvasElementis mutable. IncrementsourceVersionafter drawing a new frame.ImageBitmapis immutable. Pass a new bitmap object when its pixels change. Glasswave normalizes its orientation before upload so it matches image, video, and canvas sources.- Transparent sources use straight-alpha upload and explicit premultiplied composition, avoiding colored fringes around translucent PNG or canvas pixels.
- Rapid URL or element replacement is generation-safe: superseded loads are aborted and cannot overwrite a newer source.
- A failed replacement reports
onError/@errorbut keeps the last valid frame and active scene instead of flashing or clearing the glass. - Decoded video-frame uploads pause while the backdrop is outside the viewport, the page is hidden, or WebGL is unavailable, then resume with the latest frame.
- Pending loads, media listeners, and frame callbacks are removed on source replacement, unmount, and context loss.
- Scroll, resize, browser zoom,
visualViewportchanges, and phone orientation changes keep the visible source and optical scene aligned.
const [sourceVersion, setSourceVersion] = useState(0)
function paint() {
drawNextFrame(canvas)
setSourceVersion(version => version + 1)
}
<LiquidBackdrop source={canvas} sourceVersion={sourceVersion}>
<LiquidGlass />
</LiquidBackdrop>For video, set muted and playsInline when autoplay is required on mobile. Cross-origin image and video sources must allow CORS before their pixels can be uploaded to WebGL2. Source failures are reported through Vue @error or React/Next.js onError. Glasswave owns only its loaders and GPU upload callbacks; your application still owns playback, object URLs, streams, and ImageBitmap.close().
See Dynamic backdrop sources for lifecycle, carousel, canvas, video, CORS, and cleanup examples.
Core API
LiquidBackdrop
| Prop | Type | Default | Purpose |
| --- | --- | --- | --- |
| as | element type | 'div' | Root element. |
| source | string \| HTMLImageElement \| HTMLVideoElement \| HTMLCanvasElement \| ImageBitmap | unset | Dynamic visual source. Takes precedence over src. |
| sourceVersion | string \| number | 0 | Explicit frame version for a mutable canvas or another manually updated source. |
| src | string | '' | Image sampled by descendant lenses. |
| color | string | 'transparent' | Background color and color-only texture source. |
| alt | string | '' | Alternative text for the internal image. |
| fit | 'contain' \| 'cover' \| 'fill' \| 'none' \| 'scale-down' | 'cover' | Image fitting and optical alignment. |
| position | string | '50% 50%' | Image position. |
| width, height | number | unset | Intrinsic image dimensions, not CSS layout dimensions. |
| gpuMaxDpr | number | 3 | Maximum device-pixel ratio of the GPU canvas. |
| gpuMaxPixels | number | 48_000_000 | Approximate total pixel budget for the uploaded source and scene render targets. Internal DPR is reduced automatically when required. |
| morphController | GlasswaveMorphController | unset | Framework-neutral manual relationship controller. |
| debugMorph | boolean | false | Development overlay for ids, gaps, and active links. |
LiquidBackdrop also reports scene lifecycle and diagnostics:
| Event / callback | Payload | Purpose |
| --- | --- | --- |
| Vue @ready / React onReady | GlasswaveSceneMetrics | First GPU frame is ready. |
| Vue @metrics / React onMetrics | GlasswaveSceneMetrics | Updated runtime measurements after rendered work. |
| Vue @error / React onError | Error | Initialization, texture upload, or lens snapshot failed. |
| Vue @contextlost / React onContextLost | Error | The browser lost the WebGL2 context. |
| Vue @contextrestored / React onContextRestored | none | The browser restored the context and Glasswave is rebuilding it. |
interface GlasswaveSceneMetrics {
backend: 'webgl2'
fps: number
gpuFrameMs: number | null
instances: number
visibleInstances: number
drawCalls: number
estimatedGpuMemoryBytes: number
dpr: number
reason: GlasswaveRenderReason
}gpuFrameMs is null when EXT_disjoint_timer_query_webgl2 is unavailable. GPU memory is an estimate of buffers and textures allocated by Glasswave, because browsers do not expose total driver memory usage. Glasswave also respects MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, and MAX_VIEWPORT_DIMS. A large or very long backdrop keeps its CSS dimensions but may render at an internal DPR below 1 instead of allocating an unsafe texture.
Size the backdrop with CSS. The width and height props describe intrinsic image dimensions.
LiquidGlass
| Prop | Type | Default | Purpose |
| --- | --- | --- | --- |
| as | element type | 'div' | Root element. |
| glassEffect | LiquidGlassEffect | internal recipe | Seven primary material parameters. |
| radius | number \| readonly number[] | 24 | One radius or four corner radii. |
| color | string | 'transparent' | Color mixed into the refracted source. |
| opacity | number | 0.75 | Material-color opacity, clamped to 0...1. |
| layer | number | computed CSS order | GPU and default DOM stacking order. |
| highlight | string | white | Surface edge-light color. |
| highlightWidth | number | 0.5 | Highlight width in CSS pixels. |
| highlightBlur | number | 0.25 | Highlight blur in CSS pixels. |
| highlightStyle | 'surface' | 'surface' | The single cross-platform highlight model. |
| outerShadow | string | built in | CSS shadow outside the lens. |
| innerShadow | string | 'none' | CSS shadow inside the lens. |
| interactive | boolean | false | Reserved compatibility flag; the base material remains static. |
| morphGroup | string \| number | unset | Joins nearby lenses that share the same group. |
| morphId | string \| number | unset | Stable identity for exact or manual relationships. |
| morphWith | readonly (string \| number)[] | unset | Exact allow-list for this member. |
| morphMode | 'auto' \| 'linked' \| 'manual' | 'auto' | Relationship source used by the member. |
| morphDistance | number | 24 | Maximum edge-to-edge gap, in CSS pixels, at which morphing starts. |
| morphHysteresis | number | 4 | Additional release gap for an active link. |
| morphSoftness | number | 48 | Smooth-union width in CSS pixels. |
| morphMaxMembers | number | 2 | Maximum members in one joined GPU surface, clamped to 2...4. |
Legacy tint, tintOpacity, and surfaceColor props remain accepted for migration. New code should use color and opacity; do not mix both contracts on one lens.
A four-corner radius follows this order:
const radius = [topLeft, topRight, bottomRight, bottomLeft]Morph API
Choose one relationship mode. All three use the same GPU material and distance calculation.
| Goal | Required API |
| --- | --- |
| Any nearby members may join | morphGroup only; default morphMode="auto" |
| Only named members may join | morphId, morphMode="linked", and morphWith |
| Application code decides the graph | morphMode="manual" and createMorphController() |
Automatic
Use this when every nearby member in the group may join:
<LiquidGlass morphGroup="toolbar" morphMaxMembers={3} />
<LiquidGlass morphGroup="toolbar" morphMaxMembers={3} />
<LiquidGlass morphGroup="toolbar" morphMaxMembers={3} />Exact relationships
Use stable ids and define the permitted peers. The recommended exact setup is symmetric:
<LiquidGlass morphGroup="toolbar" morphId="a" morphMode="linked" morphWith={['b', 'c']} />
<LiquidGlass morphGroup="toolbar" morphId="b" morphMode="linked" morphWith={['a']} />
<LiquidGlass morphGroup="toolbar" morphId="c" morphMode="linked" morphWith={['a']} />
<LiquidGlass morphGroup="toolbar" morphId="d" morphMode="linked" morphWith={[]} />This permits A-B and A-C. It denies B-C and every relationship with D. A denied pair cannot enter the same multi-member surface through another member.
Manual relationships
Create one controller, pass it to the backdrop, and mark controlled members as manual:
import { createMorphController } from '@elemeta/glasswave'
import { LiquidBackdrop, LiquidGlass } from '@elemeta/glasswave/react'
const morph = createMorphController()
morph.connectMorph('toolbar', ['a', 'b', 'c'])
export function Toolbar() {
return (
<LiquidBackdrop src="/wallpaper.webp" morphController={morph}>
<LiquidGlass morphGroup="toolbar" morphId="a" morphMode="manual" morphMaxMembers={3} />
<LiquidGlass morphGroup="toolbar" morphId="b" morphMode="manual" morphMaxMembers={3} />
<LiquidGlass morphGroup="toolbar" morphId="c" morphMode="manual" morphMaxMembers={3} />
</LiquidBackdrop>
)
}connectMorph('toolbar', ['a', 'b', 'c']) creates all pairwise links between A, B, and C. Distance still decides when the visible bridge appears.
The controller has four public methods:
morph.connectMorph(group, ids)
morph.disconnectMorph(group, ids)
morph.clearMorph(group?)
morph.getMorphState(group?)Vue uses the same controller with :morph-controller="morph" and kebab-case component props.
Stability, events, and diagnostics
morphDistancealone controls the activation gap; changingmorphSoftnessdoes not silently shrink it.morphHysteresisadds release distance without changing bridge thickness.morphSoftnesscontrols bridge width and viscosity.morphMaxMemberslimits one surface to2,3, or4members.- Vue emits
morphstart,morphchange, andmorphend. - React and Next.js expose
onMorphStart,onMorphChange, andonMorphEnd. debugMorphonLiquidBackdropshows ids, allowed links, contour gaps, and active links during development.- Without WebGL2, semantic DOM content remains usable but the bridge is unavailable.
See Morphing surfaces for controller state, event payloads, dynamic mounting, selection rules, and troubleshooting. Normal mounting, resizing, scrolling, pointer activity, CSS transitions, and animations invalidate the scene automatically. Vue template refs and React refs expose:
interface LiquidGlassHandle {
readonly el: HTMLElement | null
updateRefraction(): void
updateBackdrop(): void
}Use these update methods only after geometry or source transforms that browser observers cannot detect.
Images and CORS
Same-origin image and video sources work without additional configuration.
A cross-origin image or video must return a valid Access-Control-Allow-Origin header. Without it, the browser may display the media but WebGL and the synchronized presentation canvas cannot read its pixels.
For decorative images, use alt="".
For an element source, set crossOrigin = 'anonymous' before assigning src. Glasswave URL strings do this automatically.
Canvas sources are same-origin unless application code has drawn cross-origin pixels into them. A tainted canvas cannot be uploaded to WebGL. Glasswave reports an actionable error and sets the backdrop status to failed.
Rendering and performance
- One
LiquidBackdropcreates one WebGL2 canvas. - Non-overlapping descendant lenses use one instanced draw call; only overlapping sets require ordered compositor passes.
- Lenses outside the backdrop, viewport, or an ancestor clipping region are removed before GPU upload.
- Lens rectangles and material snapshots are cached; scroll, resize, material, backdrop, and transform changes invalidate only the required state.
- Static scenes render on demand rather than running a permanent animation loop.
- Frost textures are prepared once per backdrop, not through one CSS filter per lens.
dispersion: 0uses the least expensive optical path.gpuMaxDprlimits canvas resolution and defaults to3.- Fifty lenses under one backdrop are substantially cheaper than fifty separate backdrops.
- Refraction is local edge lensing; Glasswave does not uniformly zoom the complete source image.
For dense mobile interfaces, reduce gpuMaxDpr before changing the material design:
<LiquidBackdrop :gpu-max-dpr="2" />Browser behavior
Glasswave requires WebGL2 for optical refraction and targets current evergreen browsers:
- Chromium-based desktop and Android browsers
- current Safari on iPhone, iPad, and macOS
- Firefox with WebGL2 enabled
If WebGL2 initialization or texture upload fails, semantic DOM content, the surface fallback, shadows, focus, and interaction remain available. Optical refraction and ordered glass-over-glass composition are unavailable; Glasswave does not start a second renderer.
Small platform differences can still occur because GPU drivers, display density, antialiasing, color management, and browser compositing are not identical.
Troubleshooting
The effect looks like blur only
Use a detailed backdrop and lower frost. A soft or flat background can hide displacement. Then increase refraction gradually.
Refraction is missing
Check that:
LiquidGlassis a descendant ofLiquidBackdrop.- The backdrop and lens have non-zero CSS dimensions.
- The backdrop has an image or non-transparent color.
- The image request succeeds.
- Cross-origin images return a valid CORS header.
- WebGL2 is enabled on the device.
The image looks pixelated
Use a source image with enough intrinsic resolution. If performance allows it, increase gpuMaxDpr up to the default cap of 3.
The image is optically misaligned
Keep the visible image and GPU source under the same LiquidBackdrop. Use the backdrop's fit and position props instead of independently styling another image copy.
Package entry points
| Import | Contains |
| --- | --- |
| @elemeta/glasswave | Shared types, material resolver, effect recipes, and framework-neutral exports. |
| @elemeta/glasswave/core | Explicit framework-neutral entry. |
| @elemeta/glasswave/vue | Vue components and plugin registration. |
| @elemeta/glasswave/react | React components. |
| @elemeta/glasswave/next | React components behind a client boundary. |
| @elemeta/glasswave/style.css | Required shared styles. |
Glasswave is a library, not a command-line program. There is no glasswave --help command; use the package imports shown above.
Extended documentation
The complete source documentation is available on GitHub:
- Getting started
- Material and layering
- Material parameters
- Core API
- Morphing surfaces
- Dynamic backdrop sources
- Browser support and troubleshooting
- Architecture
License
Licensed under Apache-2.0. See NOTICE for legal notices.
