description: Using LiquidGlass outside Compose: the web component, CanvasKit, React Native, Flutter, .NET, and the renderer contract for other stacks.¶
Porting LiquidGlass¶
The goal is reusable rendering, independent of the product around it. A renderer still needs access to its backdrop. No library can silently read protected content or another application's pixels. Separate the material, motion and host integration instead of duplicating a sample app.
Available paths¶
| Host | Integration today | Status |
|---|---|---|
| Android Compose / Compose Desktop / Compose for Web | LiquidGlassTheme at the root and Modifier.glassEffect |
Main implementation; shader and interaction suites |
| Android Views | Place a ComposeView over a Compose-recorded backdrop, or supply an independent renderer | Bridge work required for a View-owned backdrop; no transparent capture of arbitrary Views |
| React, Vue, Svelte, Angular, vanilla HTML | The <liquid-glass> web component (ports/npm): live HTML through backdrop-filter, or the production shaders over a supplied source through CanvasKit; or the ES module in ports/web with a supplied canvas/image/video texture |
Web component on npm (liquidglass); its HTML engine is an approximation (refraction in Chromium only), its canvas engine draws the production shaders. ports/web is the continuous lens preview, not the full measured material |
| CanvasKit / another Skia RuntimeEffect host | Generated production material, content and aperture shaders in ports/skia |
CanvasKit software rendering verified against JVM pixels; host capture/input remain explicit |
| .NET / SkiaSharp | GlassPainter, CalmInteraction, CalmNavigation / CalmSelectorController, optional feedback, WinForms ControlBackdrop, WPF/Avalonia VisualBackdrop and the MAUI GlassSurface |
CPU fixtures, input, the Calm selector against Kotlin trajectories and standard-control capture verified; MAUI on a Pixel 7 (Windows target built, not run) |
| Electron, Tauri, browser WebViews | Same web module, where WebGL and origin-clean textures are available | Host lifecycle/permissions must be tested |
| React Native (Android, web) | @shayxo.dev/liquidglass-react-native (ports/react-native): GlassSurface and GlassLens in a React Native Skia <Canvas>, the production painter through an adapter |
On npm from 0.2.1; the adapter equals CanvasKit pixel for pixel in React Native Skia's headless build; run on a Pixel 7 and the emulator; backdrop is a snapshot |
| Flutter (Android, Impeller) | liquiglass, liquidglass for Flutter (ports/flutter): GlassSurface over the live backdrop through ImageFilter.shader, its shader generated from the production SkSL |
pub.dev liquiglass (first upload pending; until then from source); shader compiles and loads, uniforms equal the CanvasKit port's; wide tone kernel approximated; rounded rectangles only |
| Qt Quick (Qt 6) | GlassSurface (ports/qt): a ShaderEffect with the material generated from the production SkSL, over a live ShaderEffectSource of the item behind it |
Shaders compiled with qsb and the PySide6 example rendered offscreen (Mesa, software) in CI; material equals the CanvasKit port's; no GPU or desktop-OS check yet |
| Unity (Built-in, URP) | com.shayxo.liquidglass (ports/unity): the material as generated HLSL, Built-in (GrabPass) and URP (opaque texture) shaders, GlassSurface |
FXC ps_5_0 compile and a D3D11 WARP render in CI; material equals the CanvasKit port's; never run in Unity |
| SwiftUI, other native hosts | Implement the renderer contract below or embed a supported surface | No dedicated adapter; SwiftUI is not a target (iOS 26 draws Liquid Glass natively) |
The web preview source has no runtime package dependency. Copy the modules with their Apache-2.0 notices, or vendor them at a pinned revision. The package is intentionally unpublished while its API is experimental.
For the production shader path, the Skia adapter exports the evaluated Kotlin shader strings and binds uniforms by reflected name/offset. A JVM test guards against source drift. CanvasKit tests compile every pass and compare the four material profiles with independent JVM reference pixels. This avoids rewriting the material for every Skia-based framework. It adds the CanvasKit WASM runtime; the smaller WebGL lens preview remains dependency-free.
For a simple production-shader lens, createGlassPainter(CanvasKit) compiles once and
owns uniform binding and temporary resources. Call setSource(backdropImage), then
drawLens(canvas, {x, y, width, magnification}) for each lens. The source remains caller-owned;
dispose() releases the painter. The executable terminal example
uses this exact API. For a rounded in-app card, the same painter provides:
glass.drawSurface(canvas, { x: 40, y: 240, width: 260, height: 88, radius: 22 },
{ dark: true, tintAmount: 50 });
It owns the fine/wide kernel setup and maps the production GlassStyle.inApp parameters, checked
against Kotlin-generated fixtures. The wide tone image is built once per source/density/canvas and
reused while controls move; it is released on replacement or disposal. A native-canvas identity check
prevents repeated JavaScript wrappers from causing a blur rebuild each frame. No CPU readback is used.
density scales dp material constants; geometry stays in device pixels. Radius can be one value or
four corner values. Host preferences map to reducedTransparency and increasedContrast (0–1).
See the surface API
for all defaults, memory cost and remaining host responsibilities. Refracted foreground, navigation,
arbitrary path fields and other material roles still use the lower-level adapter.
Its browser example demonstrates an explicit Skia WebGL surface, keyboard/pointer controls and context
recreation. Serve it locally with node ports/web/serve.mjs, then open /skia/; install the pinned
CanvasKit dependency in ports/skia first. No framework wrapper or external runtime service is needed.
The raw shaders also work as a starting point for C++ or other bindings of Skia Runtime Effects. A shared shader is not a complete host adapter: backdrop capture, blur-strip construction, compositing, gestures, accessibility and GPU lifecycle still belong to the integration. No arbitrary-view capture or drop-in support for untested frameworks is claimed.
The small contract¶
For native .NET, the SkiaSharp adapter
embeds the production shaders at build time. GlassPainter.SetSource(SKImage) borrows a shared
backdrop; DrawLens and DrawSurface draw directly on the host canvas. RecordBackdrop can own a
snapshot of a host-supplied drawing callback, excluding glass overlays. Its Windows Forms example
demonstrates actual control painting, dragging, keyboard movement, resize and preference controls.
The renderer targets .NET 8; the desktop example needs .NET 10 and Windows. Native Windows CPU
pixels match the four independent JVM profile fixtures exactly. The adapter has Windows/Linux
renderer/controller CI and Windows host/control-capture checks; additional hosts/GPU execution
are not verified. CalmInteraction supplies timestamped whole-surface press/pull with viewport,
release/re-grab, reduced-motion and resize handling. DrawSurface(..., feedback) applies the
material transform while preserving labels/hit targets.
The separate LiquidGlass.WinForms adapter captures an application's own Panel/Label/TextBox/
CheckBox subtree after layout on its UI thread. Returned opaque images are caller-owned and can
be borrowed by the painter. Refresh after content/layout changes, keeping glass outside the
subtree. RichTextBox/ActiveX are rejected; custom controls/overlapping order need host verification.
The Calm navigation selector is ported to CanvasKit and .NET (see below); arbitrary-widget capture
across stacks and high-level separate foreground passes remain open. See the native guide for build commands, ownership and examples.
For a CanvasKit host, start with createGlassScene from the local liquidglass-skia-preview package.
It combines one borrowed source image, multiple lenses/cards, calm interaction and drawing transforms.
This removes per-widget matrix setup and uses logical coordinates with an explicit pixelRatio.
The host still supplies input, labels, accessible controls, frame scheduling and a matching source/canvas.
The root scene, painter and interaction exports have TypeScript declarations; additional native
bindings remain separate work. Whole-surface .NET calm feedback and the Calm navigation selector
(scene.addNavigation; .NET CalmNavigation) are implemented and checked against Kotlin-exported
trajectories; they draw the bar and selector in the Compose Calm material itself, checked against
Kotlin-computed uniforms. See the
scene example and lifecycle.
const scene = createGlassScene(CanvasKit, { width: 800, height: 600 });
scene.setSource(backdropImage);
const panel = scene.addSurface({ x: 24, y: 440, width: 320, height: 96, radius: 24 }, { dark: true });
panel.press(timeInSeconds, cumulativeDx, cumulativeDy);
const animateAgain = scene.draw(canvas, timeInSeconds);
// Draw ordinary content, flush, and schedule again while animateAgain is true.
// On cancellation: panel.release(timeInSeconds). On unmount: scene.dispose().
The following contract remains useful for other rendering backends and more specialized hosts.
- Record the backdrop once. Supply an opaque texture for the region behind glass, with a declared top-left origin, pixel density and colour space. Exclude the glass overlays to avoid recursive feedback.
- Supply geometry in one frame. A surface has local dimensions, shape, a transform into the backdrop and padding for displaced samples. Keep hit testing and ordinary text in layout coordinates.
- Evaluate material and ink consistently. A standalone lens uses the same source map for both. A navigation selector has a separate role-specific model; do not substitute it for the magnifier.
- Compose premultiplied output once. Apply coverage once after combining material and optional refracted foreground. Double masking creates dark rims. Document alpha and colour conversions.
- Feed timestamped input. Keep press, motion, strain and navigation selection separate. Cancelled gestures must recover without selecting. Bound targets before spring integration, including huge drags.
- Own lifecycle and accessibility. Release textures/listeners, recreate resources after context loss, respect reduced motion, keep text readable with reduced transparency, and preserve semantic controls.
The Compose implementation records a padded layer and uses SkSL/AGSL; the web preview accepts a caller-owned texture and draws the continuous lens in GLSL. Browser texture uploads follow origin/CORS restrictions: see MDN's texture guide. Resources must be rebuilt after WebGL context restoration.
Combining refracted ink with glass¶
For a selector that refracts foreground, keep these inputs premultiplied and in the same local,
padded pixel coordinates: opaque material B1, selected ink (c1, a1), and aperture coverage m.
The final output must be (m * (c1 + (1-a1) * B1), m). Source-over that output onto the ordinary
page once. Masking the ink and material separately creates a cross term at the edge.
The production endpoint shader now accepts three children: endpoint, ink, and field. Bind
all three even when a branch does not sample them. With uInkStrip = 0, endpoint is the already
composed opaque C1; this preserves the original path. With uInkStrip > 0, endpoint supplies
opaque B1 and ink supplies unmasked premultiplied ink at the same coordinates. Composition
and coverage happen in float before one output write. uInkStrip also gives the visible padded
height; rows at or below it return transparent. A host with separate image shaders can bind them
directly without building a packed texture.
Compose Desktop packs these inputs into two vertical strips in one recording. Its image-filter graph offsets the ink input by the negative padded height. That offset must be declared in the graph: the zero-radius Skia RuntimeShader filter assumes same-coordinate sampling. An offset hidden inside the shader lost selected labels near the native window edge, despite passing uncropped software tests. The corrected graph preserves offscreen ink. Do not copy the rejected shader-offset approach into a host adapter.
GlassTabBarStyle.Calm() selects this path on Desktop. Android's current Java RenderEffect API
exposes one dynamic runtime-shader input, so it retains the original compositor and its known
rounding limitation. This is explicit backend scope, not a claim of equal precision on Android.
The packed desktop recording has twice the endpoint-layer area, before backend allocation overhead;
the material and ink layers themselves are unchanged. See the research record
for the measured error and native redraw evidence. The simpler magnifier painter does not use this path.
Resting inset tint and the bar footprint¶
A recovering navigation selector can remain taller than its bar after held optical formation fades. Applying its dark resting tint to the entire raised body paints dark caps onto the page. The Calm renderer limits that tint and tonal lift to a cached alpha mask of the actual underlying bar shape. Its refraction, lighting and body coverage remain independent.
For a pose body, uTintMaskX and uTintMaskY are inverse affine rows from local coordinates without
padding into native mask pixels: (m00, m01, tx, enabled) and (m10, m11, ty, unused).
When enabled, the field child supplies alpha coverage instead of the otherwise-unused pose distance
field. Other materials leave uTintMaskX.w = 0 and keep the usual distance-field behavior.
The native mask includes a transparent border and the inverse map includes the bar's press/pull
transform. At reduced material resolution, divide the two linear coefficients by render scale;
the native mask and translation stay unchanged. Do not enable this alpha role on a material that
also requires the same child for a generic distance field.
Bind a separate image shader for this mask. A prototype read a packed alpha strip from the dynamic
backdrop input; native GPU cropping removed part of that mask despite passing software tests. It was
rejected. The current mask follows the real custom Shape, not a hardcoded navigation capsule.
Web integration¶
import { createGlassRenderer } from './liquidglass.mjs';
const renderer = createGlassRenderer(outputCanvas);
renderer.setSource(backdropCanvas);
renderer.render({
width: 800, height: 600, x: 400, y: 300,
radius: 100, magnification: 1.25, pixelRatio: window.devicePixelRatio,
});
// Upload again when the backdrop changes; movement only needs render().
// On unmount:
renderer.dispose();
Coordinates are CSS pixels; pixelRatio controls the backing buffer (1–4). Supply a matching source
aspect ratio. Keep DOM text/buttons above the canvas for accessibility. For arbitrary DOM content,
CSS backdrop-filter is an available visual fallback; it does not implement this refractive source map.
The web component packages that fallback with the material's tint, kernels and
edge light, and, in Chromium, an SVG displacement map baked from the measured resting-corner map.
Do not scrape screenshots of the user's browser or claim this fallback is the measured material.
React can create/dispose the renderer in useEffect; Vue in onMounted/onUnmounted; Svelte in onMount
with its returned cleanup. Keep the renderer outside render/template evaluation. None requires a dedicated
framework wrapper. The example's keyboard and pointer handlers are ordinary browser events.
Portable lens mathematics¶
For a point p centred on an ellipse with half sizes h, let:
The supported magnification range is 1–2.5. The map equals identity at the ellipse boundary, with identity first derivative there. Its radial derivative is positive throughout that range: no reversed or skipped annulus. This is an authored continuous magnifier, not a recovered Apple lens prescription. The source map is shared by the production material/ink shaders and ported to the ES module.
core.mjs also exposes an exact critical spring step and settled calm deformation math in logical pixels.
The timestamped createCalmInteraction controller adds animated press/pull, viewport containment,
cancellation, resizing and reduced motion without depending on a UI framework. It does not own pointer
arbitration, illumination or animation scheduling, and it is not the navigation-selector travel model.
Portable calm feedback¶
For CanvasKit rendering, createGlassPainter(CanvasKit) includes the production shaders. Set a
caller-owned backdrop once, then use drawLens or drawSurface; there is no shader-fetch boilerplate.
The painter guide
documents setup and lifetime. Motion remains a separate controller so hosts can use their own input
and animation scheduling.
import { createCalmInteraction } from './interaction.mjs';
const motion = createCalmInteraction({
width: 320, height: 72, radius: 36,
x: 20, y: 20, viewportWidth: 360, viewportHeight: 112,
reducedMotion: false, // supply the host's preference
});
const now = () => performance.now() / 1000;
motion.press(now()); // pointer-down, only once your host owns the gesture
motion.press(now(), deltaX, deltaY); // cumulative displacement since down, in logical pixels
const state = motion.sample(now()); // call from your host's animation loop
motion.release(now()); // pointer-up OR pointer-cancel; no navigation selection
The optional pressScale (default 1.03, supported range 1…2) and pressGrowth (default 2,
nonnegative logical pixels per edge) tune only press amplitude. Positive growth caps each axis by
both limits; zero selects percentage-only growth. For the Compose Calm navigation bar role use
pressScale: 1.05, pressGrowth: 0, pullShape: 'area-preserving'. This gives 195.3px against the original 196±1px ordinary-held bar
at 186px rest height. Timing stays Calm; this does not add selector travel.
pullShape defaults to 'adaptive'. 'area-preserving' instead couples bounded growth and narrowing
in log space, matching the Compose navigation surface model. The saturated 834×186px fixture measures
835.5×204.7px, compared with about 834× 205–206px in the original prolonged drag. This is an authored
spatial fit. A material-only directional bias of at most5.6% of the short side places the selected
upward-pull edges within one pixel of the original. It does not identify input timing or Apple's
underlying mechanism; other directions use an authored extrapolation. Viewport constraints reduce
strain and resist bias further when necessary to contain the surface.
Viewport limits constrain the press target before spring interpolation, preserving gradual feedback
near an edge. Defaults for generic buttons and cards are unchanged.
deltaX and deltaY come from your gesture handler; these lines illustrate separate events, not a
complete event loop. Times are monotonic seconds, with one clock for input and drawing. Browser
timestamps are normally milliseconds: divide by 1000. A queued frame older than the latest input is
ignored; out-of-order input is rejected. Advance the old target before changing it, so extra redraws
cannot change the spring response. See requestAnimationFrame timing.
state.matrix is the row-major 2×2 [a, b, c, d] transform: x' = a*x + b*y, y' = c*x + d*y.
Apply it about the original glass centre, then add state.offset = [x, y] in logical pixels,
to the material drawing only. The offset is zero for Adaptive and reduced motion. Keep layout,
hit testing and accessibility outside both transforms. Generic cards also keep ordinary labels
outside. To match Calm navigation, apply this same drawing transform to both ordinary and selected
ink before selector optics, around the bar centre even when a selector node has a different
origin. Compose does this through deformItemsWithBar; the portable host owns its ink layers.
For a complete Calm navigation bar use scene.addNavigation (CanvasKit) or CalmNavigation (.NET);
the host still draws labels and selected ink. For a CanvasKit canvas with your own surfaces:
const [a, b, c, d] = state.matrix;
canvas.save();
try {
canvas.translate(centreX + state.offset[0], centreY + state.offset[1]);
canvas.concat([a, b, 0, c, d, 0, 0, 0, 1]);
canvas.translate(-centreX, -centreY);
glass.drawLens(canvas, bounds);
} finally {
canvas.restore();
}
// Draw ordinary labels here, outside the material transform.
Keep the matrix and geometry in the same logical coordinate system. Apply device density to the page as a whole. The Skia browser example contains the complete pointer, keyboard, redraw and preference lifecycle using this controller. The canvas-transform API is provided by CanvasKit.
| Method / result | Contract |
|---|---|
press(time, dx=0, dy=0) |
Press and retarget bounded cumulative pull; returns current state without snapping |
release(time) |
Release or cancel; recover from current geometry and velocity |
sample(time) |
Advance analytically to the frame time; returns fresh state |
resize(partialGeometry, time) |
Update dimensions/position/viewport; cancel the gesture and reset geometry |
setReducedMotion(enabled, time) |
Cancel and reset; reduced motion returns identity geometry |
state.active |
Request another frame while settling; new input must wake your loop again |
state.held, state.press |
Gesture ownership and geometric press amount; separate from glow or selection |
state.offset |
Bounded drawing bias [x, y]; apply after the centred matrix, never to host layout. Generic labels stay fixed; navigation shares it with both ink variants as described above. |
Supply both viewport dimensions for edge containment. Initially contained surfaces remain contained under press and diagonal pull; initially offscreen/oversized layouts keep the host's clipping policy. An enormous over-pull is bounded before spring integration, so it cannot store invisible travel that delays recovery. Wide bars keep their axis level, large surfaces retain the 2-pixel drag-extension cap, and smaller controls retain more deformation. These are authored Calm choices shared with the Compose implementation, not measured Apple timing or a recovered native animation implementation.
The controller owns no listeners, timers or GPU resources. Stop your frame loop and remove host event
listeners on unmount. Cancel on lost pointer capture and scrolling arbitration. A browser host should
observe changes to prefers-reduced-motion, not only read it once. Illumination, reduced transparency,
semantic navigation and platform-specific accessible controls remain separate host responsibilities.
Acceptance for a new backend¶
Run the same ramp/line/text targets at multiple densities and sizes. Verify source orientation, alpha, centre gain, boundary continuity and foreground registration. Test all four screen edges, diagonals, reversal, cancellation, resize and recovery. Report rendering and presented-frame performance separately. Capture only the app under test, label the backend and publish the exact source revision. Do not call a backend supported until an executable example and its renderer/gesture tests pass on that backend.