How I built liquid glass with WebGL for my portfolio
How I built liquid glass for my portfolio with WebGL, rounded refraction, flipped reflections, and chromatic aberration.
I built the liquid glass on my portfolio with a WebGL shader that bends content at the rounded edges, flips reflections, and adds chromatic aberration. The two labs below let you change the material and inspect how the layers are combined.
The pipeline is backdrop → rounded boundary → displaced samples → reflections → live HTML content. I render the glass beneath ordinary HTML so the card's own text stays sharp, selectable, and accessible.
I adapted ybouane/liquidglass, an MIT-licensed library, for the cards. The landing-page orbital artwork uses cached Canvas 2D curves, independently of glass. Both labs use the card renderer, with draggable panes and fullscreen views.
CSS backdrop-filter handles frost and blur. To bend and flip the image at the edges, the shader reads the backdrop at displaced coordinates. It works from a flat scene image, so this is a screen-space approximation of glass.
Try the material lab
Compare Chromatic aberration at zero and 0.4. At 0.4, red and blue fringes appear around the rim. Set Rim softness to zero to sharpen those fringes and reflections. The pane's center stays clear.
Border radius rounds the pane's corners; Z-axis radius widens its depth bevel. Corner radii are capped at half the pane's shorter side. Glass color changes the frost mixed into the backdrop.
The lab runs the site's GlassRenderer and GLSL functions over a small canvas of shapes and text, with a live HTML label above it. It doesn't need the portfolio's DOM capture system.
Drag the pane across the lettering, or focus it with Tab and use the arrow keys. Shift moves farther; Home restores its position. Reset demo restores this instance's material and position, without saving preferences or changing the portfolio.
Enter fullscreen expands the lab inside this page; Escape brings you back. Copy lab link shares its standalone page. Show magnifier adds a translucent white rectangle: move it or resize its corner to inspect the selected region in the zoomed view beside the scene (below it on mobile).
THROUGH
LAYERS
Drag the pane. Arrow keys move the focused pane; Shift moves faster, Home resets it.
This liquid glass material lab uses the site's real WebGL shader. Its canvas backdrop replaces DOM capture. The label above it stays live HTML.
Advanced material settings
Local material: 18.8% color, distortion 4, chromatic aberration 0.4, rim softness 6 px.
Interactive WebGL lab loads when nearby. Enable JavaScript to change these settings.
If WebGL is unavailable, the lab labels its CSS fallback. That fallback provides frost, but cannot reproduce the GPU's mirrored reflections or color separation.
The backdrop and live HTML
Each surface has a sampled image behind it and live content above it. The shader distorts the image while HTML keeps text selectable and controls accessible.
CSS backdrop-filter supplies blur and saturation when WebGL initialization fails. Rounded refraction, mirrored rims, and displaced color channels come from the GPU shader.
A canvas cannot read arbitrary DOM text automatically, so the site combines the lighting canvas with cached foreground images to build a scene texture. The selected navigation pill samples an image of the labels beneath it; the original links remain clickable. Text inside a content card is drawn above its glass and stays sharp.
How a card is rendered
A .glass element becomes a visible lens in six steps:
- Collect surfaces.
src/main.jsloadscreateGlassSurfacesfromsrc/liquid-glass.js, waits for fonts, and creates empty lenses in a shared scene. - Build the backdrop.
createGlassBackdropcombines the lighting canvas with ordered proxies for cards, text, the header, and the live artwork. - Read the material.
createGlassMaterialCachereads root CSS variables shared by cards and navigation. Style and viewport changes invalidate the cache. - Measure the lens.
glassSceneGeometryandglassSurfaceViewmap its position, dimensions, and hover transform into the scene, including a capture margin for edge samples. - Shade the crop.
GlassRendereruploads the image, applies any blur, then computes refraction, channel separation, reflection, frost, and the surface mask. - Place it under content. The result sits in
.glass-render-layer, beneath the card's live HTML.
The initialization in src/liquid-glass.js includes:
instance = await LiquidGlass.init({
root: scene,
backgroundColor: getComputedStyle(document.documentElement).backgroundColor,
glassElements: lenses.map(({ lens }) => lens),
defaults: materials.read(),
maxPixelRatio: 1,
captureGlassContent: false,
});
instance.pause();
captureGlassContent: false prevents a duplicate copy of the card's text. The separate backdrop system captures foreground content for other lenses to sample. pause() stops the library's continuous loop; the site's scheduler renders when the scene or geometry changes.
Composing scene layers
The layer lab exposes four independent switches. Background lighting and sampled text build the source scene. The rear pane samples it; the front pane samples the result beneath it. Live HTML labels remain above the output.
Disable Sampled text to remove the lettering from the reflections. Restore it, then disable Rear glass pane to see the front pane sample the undistorted scene. You can drag each pane independently or use the same keyboard controls as the material lab. Border radius changes both panes' corners. The front pane always paints above the rear pane.
THROUGH
LAYERS
Drag either pane. Arrow keys move the focused pane; Shift moves faster, Home resets it.
This glass layer composition lab draws text once into a canvas. Each pane samples the layers below it with the site's WebGL renderer. The hero artwork contributes one completed canvas layer.
Visible layers: background lighting and tiles, sampled text, rear glass pane, front glass pane. The HTML label is always separate.
Interactive WebGL lab loads when nearby. Enable JavaScript to change these settings.
The lab uses canvas text in place of cached foreground images. This keeps the paint-order demo separate from DOM capture and the portfolio's hover transforms.
On the portfolio, compareBackdropLayers orders page content, the fixed header, the draggable lens, and controls. DOM order breaks ties, and parents paint before nested glass. The clear navigation pill can therefore sample both its bar and labels.
The hero artwork is drawn from three cached, illuminated curves by src/hero-artwork.js. Its completed canvas becomes a single foreground source for later lenses. It does not sample or refract the backdrop.
Material settings and blur
src/glass-settings.js normalizes these shared settings. Each demo uses the same mappings with its own local values.
| Setting | CSS property | Site default | Effect |
|---|---|---|---|
| Color amount | --glass-color-amount |
0.188 (18.8%) | Frost mixed into the sampled image. |
| Glass color | --glass-frost-rgb |
79, 79, 79 (#4F4F4F) |
Frost color. |
| Blur | --glass-blur-amount |
0 | Blur across the surface. |
| Distortion | --glass-distortion |
4 | Strength of rim refraction. |
| Chromatic aberration | --glass-chromatic-aberration |
0.4 | Red/blue sample separation. |
| Z-axis radius | --glass-depth |
60 CSS px | Width of the rounded depth profile. |
Color amount mixes frost into a solid backdrop once. Lowering the completed canvas's opacity would expose the undistorted background underneath and create a double image.
Additional optical settings live in src/glass.css:
| Property | Site default | Effect |
|---|---|---|
--glass-thickness |
24 CSS px | Ray travel through the rim, bounded by the bevel. |
--glass-refraction-index |
1.45 | Index used by GLSL refract. |
--glass-edge-reflection |
0.85 | Weight of the mirrored sample. |
--glass-reflection-blur |
6 CSS px | Maximum softness of reflections and color fringes. |
--glass-header-depth-scale |
2 | Wider navigation bevel. |
--glass-playground-depth-scale |
2.5 | Wider draggable-lens bevel. |
Surface blur and rim softness are independent. Surface blur affects the transmitted backdrop broadly; rim softness fades in near the boundary. The center can stay clear while edge reflections soften.
Each displaced color channel is blurred around its own sample position, preserving red/blue separation. Zero rim softness restores sharp edges. Zero chromatic aberration removes the extra softness on transmitted color samples; mirrored reflections can still be soft.
The selected navigation pill has zero frost while sharing the material's optical settings. Specular highlights, Fresnel shine, and decorative borders are disabled by default.
Rounded edges
A signed-distance function in src/vendor/liquidglass.js describes the XY boundary: a rounded rectangle for cards, or a quartic superellipse for the draggable lens. A separate quartic cross-section rounds the surface in Z:
x⁴ + z⁴ = 1
Its gradient, proportional to (x³, z³), gives the surface normal. liquidSquircleNormal combines it with the nearest XY boundary direction, producing a steep outer edge that settles into the flat face.
liquidEdgeMask fades the effect inward using a quintic curve:
float t = clamp(inside / width, 0.0, 1.0);
float eased = t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
return clamp(1.0 - eased, 0.0, 1.0);
inside measures distance inward. The mask falls from one at the boundary to zero at the bevel's inner limit. Zero first and second derivatives at both ends give a smooth join.
liquidLensOffset then converts refraction into a backdrop sampling offset:
vec3 ray = refract(vec3(0.0, 0.0, -1.0), normal, 1.0 / max(ior, 1.001));
return ray.xy / max(-ray.z, 0.001) * thickness * 0.7 * edge * max(strength, 0.0);
The offset changes texture coordinates to approximate curved glass. Zero depth removes the bevel; zero distortion disables its sampling strength. Small controls cap the bevel width to preserve a flat center. Z radius and CSS corner radius remain separate inputs.
Flipped reflections and rim softness
Reflections mirror across the nearest local boundary. Top and bottom edges reverse vertically, side edges reverse horizontally, and corners follow their curved normal.
liquidReflectionProfile bounds the active band and outward reach:
float band = min(width, min(thickness, padding * 0.3));
if (band <= 0.001 || inside >= band) return vec2(0.0);
float reach = min(thickness * 0.6, padding * 0.2);
return vec2(2.0 * inside + reach, liquidEdgeMask(inside, band));
2.0 * inside crosses the boundary and flips the normal axis. reach samples just beyond the pane, allowing text beneath a bottom edge to appear upside down. Reflection and blur stay within the lens's 64 CSS pixels of captured padding.
GLSL and WGSL share the same bounded softness calculation:
float liquidReflectionBlurRadius(float edgeBlend, float radius, float width, float padding) {
float limit = min(max(width, 0.0) * 0.5, max(padding, 0.0) * 0.1);
return min(max(radius, 0.0), limit) * clamp(edgeBlend, 0.0, 1.0);
}
A five-tap kernel gives the center twice the weight of each neighbor, with weights totaling six. Extra samples run only where the rim radius is nonzero, without another full-screen blur pass.
A flat backdrop cannot reveal hidden objects or reflect anything outside the captured scene.
Hover and scrolling
If a card moved while its sample coordinates stayed fixed, the backdrop would look printed onto the glass. createGlassView maps the local plane to CSS perspective, lift, scale, and rotation. The shader projects sampling positions into that view and rotates the surface normals with it.
Reflections use the same transform. Nested lenses sample their parent's projected canvas without applying that transform twice. src/glass-backdrop-projection.js maps cached foreground and glass images into another lens's backdrop.
The scene is attached to the document, so page cards and their sampled coordinates scroll together. Fixed navigation updates its scene position as content passes beneath it. Foreground images are recaptured when their content or size changes; scrolling reuses the cache. Captures embed the same fonts as the live page to preserve glyph shapes and wrapping.
WebGL, WebGPU, and fallbacks
Cards, navigation, badges, the draggable lens, and both labs use the adapted WebGL renderer. The hero previously used WebGPU ribbons; it now uses a Canvas 2D sculpture to reduce rendering work.
Glass surfaces share the backdrop and material settings. The decorative artwork contributes its finished image to the scene but has no glass material.
The artwork starts only on screens at least 781px wide. Mobile hides it; Canvas setup failures leave static CSS artwork. Failed WebGL initialization leaves native frosted glass. Static text, links, and CV content remain readable without JavaScript.
Performance
Creating and uploading scene images costs CPU time and memory; the shader's texture samples cost GPU time. I use these limits:
- Cache captures. Foreground images use limited capture concurrency and refresh only affected content. Scrolling repositions the cache.
- Skip unused blur passes. At zero surface blur, both samplers use the sharp texture. Positive blur adds horizontal/vertical passes; rim softness adds taps only in its band.
- Cap resolution and motion. Cards use at most one pixel per CSS pixel; live HTML retains native resolution. Ribbons use at most 1.5 device pixel ratio and 30 FPS. Background pixels paint at up to 12 FPS.
- Schedule updates. Geometry and scene changes are batched. Scroll and drag can flush immediately. Hidden pages cancel pending work; artwork also pauses outside the viewport.
- Respect reduced motion. The CV's motion switch and system preference stop animation. Labs start still and redraw for controls and layout changes, with no idle loop.
During WebGL context loss, the renderer skips draws. Restoration rebuilds GPU resources; the next requested render can upload images again. Live HTML stays available. Production restoration is separate from the initialization fallback; the labs explicitly report a fallback if their GPU path becomes unavailable.
Larger captures, overlapping lenses, and extra samples for chromatic aberration or blur increase the cost. Performance also depends on the device.
Implementation order
For a smaller version of this effect, start with one pane:
- Put live HTML over one glass image and a background canvas.
- Establish a shared material and padded backdrop crop.
- Add a boundary-distance field, smooth rim mask, and surface normal.
- Add boundary reflections, channel offsets, and rim softness; check zero values independently.
- Match sampling coordinates to scroll and hover, then add nested surfaces.
- Capture foreground images when an overlay needs to see text, keeping the original HTML.
- Provide CSS fallback and cleanup for observers, GPU resources, and scheduled frames.
The material lab covers steps 2–4; the layer lab shows step 6. Fullscreen reuses the renderer, and the magnifier crops the completed image without another GPU pass.
Files and references
The portfolio's source is private. These are the files referenced in the explanations and excerpts above:
| Source path | Responsibility |
|---|---|
src/main.js |
Startup, motion preference, artwork lifecycle. |
src/liquid-glass.js |
Surfaces, shared scene/material, output beneath HTML. |
src/glass-backdrop.js |
Capture queue and scene ordering. |
src/glass-settings.js, src/glass.css |
Material values, clear variant, CSS fallback. |
src/glass-distortion.js |
Rim mask, normals, refraction, reflection, softness. |
src/vendor/liquidglass.js |
WebGL renderer, uploads, blur, context recovery. |
src/glass-view.js, src/glass-geometry.js |
Surface geometry and hover projection. |
src/glass-backdrop-projection.js |
Foreground and nested-glass sampling. |
src/hero-artwork.js |
Cached Canvas 2D orbital artwork. |
src/blog/demos.js |
Controlled scenes and isolated lab controls. |
For the underlying tools, see liquidglass, VGPU, MDN's backdrop-filter reference, and dialog element reference.
The third-party notices include the original authors' credits and licenses for liquidglass and its bundled html-to-image code.