Skip to content

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.

  1. 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.
  2. 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.
  3. 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.
  4. Compose premultiplied output once. Apply coverage once after combining material and optional refracted foreground. Double masking creates dark rims. Document alpha and colour conversions.
  5. 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.
  6. 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:

r² = clamp(dot(p/h, p/h), 0, 1)
m  = 1 - 1 / centreMagnification
source(p) = p × (1 - m × (1-r²)²)

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.