Apple's Liquid Glass design language makes UI elements feel like physical objects. The secret isn't just blur - it's the subtle interplay of refraction, specular highlights, and caustics that emerges from treating the panel as real curved glass. This article breaks down each technique, starting from the foundation: the squircle.
Ingredients
Three elements combine to create the Liquid Glass illusion: a smooth squircle shape with continuous curvature, beveled edges that create 3D geometry, and an optimized blur that simulates frosted glass. Each layer builds on the previous.
The Squircle: Apple's Signature Shape
Apple doesn't use rounded rectangles. They use squircles - superellipses defined by |x/a|ⁿ + |y/b|ⁿ = 1. When n=2, you get an ellipse. When n=4, you get that distinctively smooth Apple corner. As n approaches infinity, it becomes a sharp rectangle. The key difference: rounded rectangles have an abrupt curvature change where the arc meets the straight edge. Squircles have continuous curvature throughout.
// Squircle SDF: |x/a|^n + |y/b|^n = 1 // n=2: ellipse, n=4: squircle (Apple), n→∞: rectangle float sdSquircle(vec2 p, vec2 size, float n) { vec2 d = abs(p) / size; float dist = pow(pow(d.x, n) + pow(d.y, n), 1.0 / n); return (dist - 1.0) * min(size.x, size.y); } // Gradient for surface normals vec2 sdSquircleGrad(vec2 p, vec2 size, float n) { vec2 d = abs(p) / size; float sum = pow(d.x, n) + pow(d.y, n); float sumPow = pow(sum, 1.0/n - 1.0); vec2 grad = sign(p) * pow(d, vec2(n-1.0)) / size * sumPow; return normalize(grad); }
Bevel: From Flat to 3D
A flat shape looks like a sticker. To make it feel like real glass, we need depth. The bevel creates a chamfered edge that catches light differently than the flat center. We use the SDF gradient to determine the edge direction, then compute lighting based on a virtual surface normal that tilts outward in the bevel zone.
The bevel width determines how far the chamfer extends inward from the edge. The SDF gives us the signed distance - negative values are inside the shape. We use smoothstep to create a smooth transition zone where the surface normal gradually tilts from facing outward (at the edge) to facing forward (in the flat center).
// Returns bevel geometry: XY = bevel direction, Z = bevel amount (0-1) vec3 computeBevel(float sdf, vec2 grad, float bevelWidth) { // Bevel amount: 1 at edge, 0 at center float bevelAmount = 1.0 - smoothstep(-bevelWidth, 0.0, sdf); // Direction points outward (toward edge) return vec3(grad, bevelAmount); } // Usage: vec3 bevel = computeBevel(d, grad, bevelWidth); vec2 bevelDir = bevel.xy; // Direction of the chamfer float bevelAmount = bevel.z; // 0 = flat center, 1 = edge // Construct surface normal from bevel vec3 normal = normalize(vec3(bevelDir * bevelAmount, 1.0 - bevelAmount * 0.5));
Frosted Glass Blur
The frosted glass effect requires a fast, high-quality blur. We downsample the scene to half resolution first - this doubles the effective blur radius for free. Then we apply separable Gaussian passes: horizontal first, then vertical.
By sampling between texel centers, the GPU's bilinear filtering blends two texels in one fetch - cutting 9 samples down to 5.
Enlighten Us
With the geometry in place, we can simulate how light interacts with the glass. The bevel acts as a lens - its curved surface bends light according to Snell's law. This section covers refraction, specular highlights, and the caustic patterns that emerge from cylindrical lens focusing.
Refraction & Chromatic Dispersion
When light crosses the glass-air boundary, it bends according to Snell's law. But here's the key: different wavelengths bend at different angles. Blue light has a higher effective IOR than red, so it refracts more. This chromatic dispersion creates those subtle rainbow fringes at the edges - the hallmark of quality glass rendering.
Crank up the dispersion to see the color separation. Notice how the fringes only appear at the edges where the bevel curves - the flat center has no dispersion because there's no refraction there. Real glass has subtle dispersion; diamond's famous 'fire' comes from its extreme chromatic dispersion.
// Each RGB channel samples at a different offset // Blue bends more than red (higher effective IOR) float dispersionAmount = uDispersion * 0.003 * bevel.edgeFactor; vec2 dispersionDir = bevel.gradDir; vec2 uvR = baseUV - dispersionDir * dispersionAmount; // Red bends least vec2 uvG = baseUV; // Green in middle vec2 uvB = baseUV + dispersionDir * dispersionAmount; // Blue bends most vec3 color = vec3( texture2D(uBlurredScene, uvR).r, texture2D(uBlurredScene, uvG).g, texture2D(uBlurredScene, uvB).b );
Specular & Fresnel Reflection
Glass both reflects and transmits light. The Fresnel equations describe the split: at normal incidence, glass reflects ~4%. At grazing angles, it becomes a near-perfect mirror. Meanwhile, specular highlights appear where the bevel's surface normal aligns with the light reflection direction. Together, these effects make the glass feel physically present.
Drag the sun angle to see how specular and reflection work together. The specular highlight is a focused glint from the light source, while Fresnel reflection shows the entire environment. At grazing angles (the bevel edges), both effects intensify - the glass becomes almost mirror-like. The specular sharpness controls highlight size; reflection intensity scales the environment contribution.
// Fresnel using Schlick approximation float fresnelSchlick(float cosTheta, float F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); } // Blinn-Phong specular with energy normalization vec3 halfVec = normalize(viewDir + lightDir); float NdotH = max(dot(normal, halfVec), 0.0); float F = fresnelSchlick(NdotH, 0.04); float spec = pow(NdotH, shininess) * (shininess + 2.0) / 6.28; // Fresnel reflection blend float NdotV = max(dot(normal, viewDir), 0.0); float fresnel = fresnelSchlick(NdotV, 0.04); vec3 reflected = sampleEnvironment(reflect(-viewDir, normal)); vec3 glassColor = mix(refracted, reflected, fresnel); glassColor += lightColor * F * spec; // Add specular on top
Caustics
Caustics are the bright bands of light that form when a curved surface focuses rays like a lens. Think of sunlight through a wine glass creating patterns on a tablecloth. When light passes through our glass panel from behind, it refracts at the back surface's bevel and focuses on our side. The cylindrical lens shape concentrates light perpendicular to the bevel axis.
Watch the sun orbit around the glass. Caustics appear when light comes from behind - notice how the bright band traces the bevel contour as the sun passes through the back half of its orbit. Higher IOR creates stronger focusing - diamond (IOR 2.4) produces brilliant fire, while water (IOR 1.33) creates subtle pools.
vec3 computeCaustics(BevelGeometry bevel) { vec3 backNormal = vec3(0.0, 0.0, -1.0); // Back surface // Caustics appear when light comes from BEHIND (lightDir.z < 0) float backFacing = max(dot(lightDir, backNormal), 0.0); if (backFacing < 0.01) return vec3(0.0); // Back bevel is inverted - focus direction flips vec3 focusDir = vec3(-bevel.gradDir.x, -bevel.gradDir.y, 0.0); // Band shape: peaks within the bevel zone float bandShape = pow(edgeFactor * (1.0 - edgeFactor * 0.5), 0.625); // Focusing power from curvature squared × IOR factor float iorFactor = (uIOR - 1.0) / 0.5; float focusingPower = bevelAmount * bevelAmount * 2.0 * iorFactor; // Light alignment with focus direction float rawAlignment = dot(lightDir.xy, focusDir.xy); float focusAlignment = 0.3 + 0.7 * rawAlignment * rawAlignment; return lightColor * focusingPower * focusAlignment * backFacing * bandShape; }
Absorption
Real glass isn't perfectly transparent. As light travels through, some wavelengths are absorbed more than others. This is Beer's Law: intensity decays exponentially with distance. Common soda-lime glass (window glass) has iron oxide impurities that absorb red and blue more than green - giving thick glass panels their characteristic green tint at the edges.
Increase the thickness to see the green tint emerge. The center is thicker than the beveled edges, so it absorbs more - creating a gradient from green center to clearer edges. At 20cm, even the sky takes on that aquarium-glass look. Real architectural glass is typically 0.5-2cm.
vec3 computeAbsorption(BevelGeometry bevel, vec3 color) { // Beer39;s Law: I = I₀ × e^(-α × d) // α = absorption coefficient (per cm), d = path length(cm) // Soda-lime glass absorption coefficients (cm⁻¹) // Iron oxide impurities cause the green tint vec3 absorptionCoeff = vec3(0.02, 0.005, 0.015); // R, G, B // Path length: thicker in center, thinner at edges float pathLength = (1.0 - bevel.edgeFactor * 0.5) * uThickness; // Exponential falloff vec3 transmission = exp(-absorptionCoeff * pathLength); return color * transmission; }
Putting It All Together
Real scenes have multiple light sources, each casting its own specular highlights, caustics, and coloring the environment. The final demo combines all effects with three independent lights. Each light has a direction (controlled by horizontal angle and elevation) and a color that tints both the sky and the glass effects.
The three lights create a classic three-point setup: warm sun as key light, cool blue fill for shadow detail, and orange rim for edge definition. Each light contributes independently to specular highlights, caustics, and environment coloring. Drag the position controls to orbit lights around the glass - notice how caustics only appear when a light is behind the panel.
// Convert spherical coordinates to 3D direction vec3 getLightDirFromAngles(float angle, float elevation) { float cosElev = cos(elevation); return normalize(vec3( sin(angle) * cosElev, // X: horizontal position sin(elevation), // Y: vertical position cos(angle) * cosElev // Z: depth (front/back) )); } // Accumulate specular from all lights vec3 computeSpecularHighlights(BevelGeometry bevel) { vec3 specular = vec3(0.0); // Each light contributes independently specular += computeSpecularFromLight(normal, viewDir, light0.direction, light0.color, light0.intensity); specular += computeSpecularFromLight(normal, viewDir, light1.direction, light1.color, light1.intensity); specular += computeSpecularFromLight(normal, viewDir, light2.direction, light2.color, light2.intensity); return specular; } // Environment also samples all lights for sky coloring vec3 sampleEnvironmentMultiLight(vec3 dir) { float influence0 = computeLightInfluence(dir, light0.direction); float influence1 = computeLightInfluence(dir, light1.direction); float influence2 = computeLightInfluence(dir, light2.direction); vec3 combinedColor = light0.color * influence0 * light0.intensity + light1.color * influence1 * light1.intensity + light2.color * influence2 * light2.intensity; // ... apply to sky gradient and clouds }