Experiments

Liquid Glass

Liquid Glass rebuilt as real refractive geometry with Three.js

June 1, 2026

Apple's Liquid Glass, the lens material from iOS 261, rebuilt as an actual piece of glass with Three.js. It is real refraction through real geometry, not a backdrop-filter blur with an SVG displacement map faking the bend. I built it together with an AI agent, going back and forth until the motion felt right. The full code is at the end.

What went wrong first

We started with plain CSS. It was not reliable, and it was not as good as the real material. I could see it wasn't looking 100% right, so I asked the agent to switch to Three.js, which I had used before.

From there it was back and forth. For the settings that decide how the glass feels, we added UI controls. I could move them and see what felt best, instead of describing it in words and waiting for the next version.

How it works

The scene itself is tiny. A capsule extruded with a fat bevel, a physical material turned up to full transmission2 so the mesh renders whatever sits behind it, and a room environment map for the highlights. Transmission bends that light through an index of refraction on the way past. Dispersion refracts each color channel at a slightly different one, so the edges fringe into rainbow. The lens drifts after the pointer with a small tilt that sweeps the highlight across it. There is no hand-written shader anywhere, only material settings.

The bevel is the entire effect. Light hits the flat front face straight on and passes through almost unbent, so a flat slab shows you nothing but a faint offset. All the liquid lives in the curve at the edge, where the surface turns away and rays bend hard enough to smear, stretch and magnify the text behind it. Give that bevel too few segments and the whole thing dies on the spot.

The faceted bevel

ExtrudeGeometry ships flat-shaded normals, so every bevel segment is its own facet. The lens caught light like a cut gemstone, with hard sparkly bands around the edge instead of one smooth highlight. The fix takes three lines: delete the normals, weld the mesh back together with mergeVertices3, and recompute them. It goes from rhinestone to liquid.

If you're about to build one

transmission refracts the scene render, not the page. The DOM behind the canvas is invisible to it, so a transparent canvas floating over real page content simply doesn't work. The gradient and the text you see bending are a textured plane inside the scene, sized from the camera frustum rather than in pixels, so it survives any container width.

The demo also checks prefers-reduced-motion. If you've asked your OS for less movement, the loop never starts and you get a single static frame. It is still glass and still refracting, just holding still.

The code

LiquidGlass.tsx

"use client"
import { useEffect, useRef } from "react"
import * as THREE from "three"
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"
import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js"
const FOV = 30
const CAMERA_Z = 18
const LENS_Z = 1.4
const LENS_WIDTH = 4.6
const LENS_HEIGHT = 2.4
const LENS_MAX_COVERAGE = 0.85
const BEVEL = 0.5
const PIXELS_PER_UNIT = 51.2
/*
* The fraction of the remaining distance the lens closes in one 60Hz frame. It is applied per
* elapsed millisecond rather than per callback, so the follow takes the same wall-clock time on a
* 120Hz display as on a 60Hz one. Per callback it was twice as fast on a 120Hz screen, and under
* the recorder's stepped clock - which fires many callbacks inside one captured frame - it closed
* the whole distance in about seven frames and the clip read as the lens teleporting.
*/
const FOLLOW_PER_FRAME = 0.06
const FRAME_MS = 1000 / 60
const MAX_TEXTURE_SIZE = 4096
const FONT_SIZE = 44
const LINE_HEIGHT = 80
const TEXT = "Liquid Glass "
const SURFACE = { light: "oklch(0.985 0 0)", dark: "oklch(0.145 0 0)" }
const INK = { light: "oklch(0.145 0 0)", dark: "oklch(0.985 0 0)" }
const BLOB_LIGHTNESS = { light: 0.82, dark: 0.47 }
const BLOBS = [
{ hue: 275, chroma: { light: 0.072, dark: 0.236 }, x: 0.27, y: 0.29 },
{ hue: 346, chroma: { light: 0.101, dark: 0.159 }, x: 0.76, y: 0.45 },
{ hue: 181, chroma: { light: 0.118, dark: 0.068 }, x: 0.45, y: 0.74 },
]
interface BackdropOptions {
dark: boolean
width: number
height: number
}
function createBackdropTexture({ dark, width, height }: BackdropOptions) {
const canvas = document.createElement("canvas")
canvas.width = Math.min(Math.round(width), MAX_TEXTURE_SIZE)
canvas.height = Math.min(Math.round(height), MAX_TEXTURE_SIZE)
const ctx = canvas.getContext("2d")
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
if (!ctx) return texture
const mode = dark ? "dark" : "light"
ctx.fillStyle = SURFACE[mode]
ctx.fillRect(0, 0, canvas.width, canvas.height)
const lightness = BLOB_LIGHTNESS[mode]
const radius = Math.max(canvas.width, canvas.height) * 0.37
for (const blob of BLOBS) {
const centerX = blob.x * canvas.width
const centerY = blob.y * canvas.height
const color = `oklch(${lightness} ${blob.chroma[mode]} ${blob.hue}`
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius)
gradient.addColorStop(0, `${color})`)
gradient.addColorStop(1, `${color} / 0)`)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.fillStyle = INK[mode]
ctx.font = `600 ${FONT_SIZE}px ui-sans-serif, system-ui, sans-serif`
const wordWidth = ctx.measureText(TEXT).width
const line = TEXT.repeat(Math.ceil(canvas.width / wordWidth) + 1)
const rows = Math.ceil(canvas.height / LINE_HEIGHT)
for (let row = 0; row <= rows; row++) {
const offset = row % 2 === 0 ? -wordWidth * 0.3 : -wordWidth * 0.8
ctx.fillText(line, offset, LINE_HEIGHT * row + FONT_SIZE + 12)
}
return texture
}
function createLensGeometry() {
const radius = (LENS_HEIGHT - BEVEL * 2) / 2
const x = (LENS_WIDTH - BEVEL * 2) / 2 - radius
const shape = new THREE.Shape()
shape.absarc(x, 0, radius, -Math.PI / 2, Math.PI / 2)
shape.absarc(-x, 0, radius, Math.PI / 2, (Math.PI * 3) / 2)
const extruded = new THREE.ExtrudeGeometry(shape, {
depth: 0.35,
bevelEnabled: true,
bevelThickness: BEVEL,
bevelSize: BEVEL,
bevelSegments: 12,
curveSegments: 48,
})
extruded.deleteAttribute("normal")
extruded.deleteAttribute("uv")
const geometry = mergeVertices(extruded)
geometry.computeVertexNormals()
geometry.center()
return geometry
}
function createLensMaterial() {
return new THREE.MeshPhysicalMaterial({
transmission: 1,
thickness: 1.6,
roughness: 0.03,
ior: 1.5,
dispersion: 5,
clearcoat: 1,
clearcoatRoughness: 0.15,
})
}
function createScene(container: HTMLDivElement) {
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.toneMapping = THREE.ACESFilmicToneMapping
container.appendChild(renderer.domElement)
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 50)
camera.position.z = CAMERA_Z
const pmrem = new THREE.PMREMGenerator(renderer)
const environment = pmrem.fromScene(new RoomEnvironment()).texture
scene.environment = environment
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)")
const backdropMaterial = new THREE.MeshBasicMaterial()
const backdrop = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), backdropMaterial)
scene.add(backdrop)
const backdropHeight = 2 * Math.tan((FOV * Math.PI) / 360) * CAMERA_Z
function updateBackdrop() {
const backdropWidth = backdropHeight * camera.aspect
backdrop.scale.set(backdropWidth, backdropHeight, 1)
backdropMaterial.map?.dispose()
backdropMaterial.map = createBackdropTexture({
dark: darkQuery.matches,
width: backdropWidth * PIXELS_PER_UNIT,
height: backdropHeight * PIXELS_PER_UNIT,
})
}
const lens = new THREE.Mesh(createLensGeometry(), createLensMaterial())
lens.position.z = LENS_Z
scene.add(lens)
const target = new THREE.Vector2()
const position = new THREE.Vector2()
const halfHeight = Math.tan((FOV * Math.PI) / 360) * (CAMERA_Z - LENS_Z)
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect()
target.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
1 - ((event.clientY - rect.top) / rect.height) * 2,
)
}
function onPointerLeave() {
target.set(0, 0)
}
let raf = 0
let previousTime: number | undefined
function tick(time: number) {
// clamped so a backgrounded tab returning after a long gap eases in rather than snapping
const elapsed = Math.min(time - (previousTime ?? time - FRAME_MS), FRAME_MS * 4)
previousTime = time
const follow = 1 - (1 - FOLLOW_PER_FRAME) ** (elapsed / FRAME_MS)
const halfWidth = halfHeight * camera.aspect
const rangeX = Math.max(halfWidth - (LENS_WIDTH * lens.scale.x) / 2, 0)
const rangeY = Math.max(halfHeight - (LENS_HEIGHT * lens.scale.y) / 2, 0)
position.x += (target.x * rangeX - position.x) * follow
position.y += (target.y * rangeY - position.y) * follow
lens.position.set(position.x, position.y + Math.sin(time * 0.0009) * 0.1, LENS_Z)
lens.rotation.set(
-position.y * 0.1 + Math.sin(time * 0.0007) * 0.06,
position.x * 0.1 + Math.cos(time * 0.0005) * 0.06,
0,
)
renderer.render(scene, camera)
raf = requestAnimationFrame(tick)
}
const observer = new ResizeObserver(() => {
const width = container.clientWidth
const height = container.clientHeight
if (!width || !height) return
renderer.setSize(width, height)
camera.aspect = width / height
camera.updateProjectionMatrix()
const visibleWidth = 2 * halfHeight * camera.aspect
lens.scale.setScalar(Math.min(1, (visibleWidth * LENS_MAX_COVERAGE) / LENS_WIDTH))
updateBackdrop()
renderer.render(scene, camera)
})
observer.observe(container)
function onThemeChange() {
updateBackdrop()
renderer.render(scene, camera)
}
darkQuery.addEventListener("change", onThemeChange)
const isStatic = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!isStatic) {
container.addEventListener("pointermove", onPointerMove)
container.addEventListener("pointerleave", onPointerLeave)
raf = requestAnimationFrame(tick)
}
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
darkQuery.removeEventListener("change", onThemeChange)
container.removeEventListener("pointermove", onPointerMove)
container.removeEventListener("pointerleave", onPointerLeave)
backdropMaterial.map?.dispose()
backdropMaterial.dispose()
backdrop.geometry.dispose()
lens.geometry.dispose()
lens.material.dispose()
environment.dispose()
pmrem.dispose()
renderer.dispose()
renderer.domElement.remove()
}
}
export function LiquidGlass() {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
return createScene(container)
}, [])
return <div ref={containerRef} style={{ position: "absolute", inset: 0 }} />
}

References

  1. 1.Adopting Liquid Glass · Apple Developer
  2. 2.MeshPhysicalMaterial · Three.js
  3. 3.BufferGeometryUtils.mergeVertices · Three.js

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

Fluid Input

A fluid search input with Motion