Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 1 | /* The lens: a refraction map and four rim masks for a rounded box, computed from a |
| 2 | physical profile instead of drawn by hand. Everything here is pure, so it runs in |
| 3 | tests and workers as well as the page. |
| 4 | |
| 5 | The profile is rendered once as a rounded square of side 2r + 1, cut into nine |
| 6 | slices: fixed corners and stretched edges keep it exact at any size, so the glass |
| 7 | never has to switch off while its box resizes. */ |
| 8 |
| 9 | const PROFILE_STEPS = 128; |
| 10 | // The bottom-right glint, relative to the top-left one: the light's return off the far curve. |
| 11 | const GLINT_RETURN = 0.6; |
| 12 |
| 13 | /** Slice order shared by masks and the refraction filter. */ |
| 14 | export const SLICES = ["tl", "tr", "bl", "br", "t", "b", "l", "r"] as const; |
| 15 |
| 16 | export type GlassGeometry = { |
| 17 | /** Width of the refracting rim along the top and bottom, in CSS pixels. The centre is flat. */ |
| 18 | bezel: number; |
| 19 | /** Glass depth under the top and bottom rims, in CSS pixels. Deeper glass bends further. */ |
| 20 | thickness: number; |
| 21 | /** Width of the refracting rim at the sides and rounded ends, in CSS pixels. */ |
| 22 | sideBezel: number; |
| 23 | /** Glass depth under the sides and rounded ends, in CSS pixels. */ |
| 24 | sideThickness: number; |
| 25 | refractiveIndex: number; |
| 26 | /** Rim hairline width, in CSS pixels. */ |
| 27 | hairline: number; |
| 28 | /** Share of the hairline's strength that reaches the sides (0–1). */ |
| 29 | hairlineSides: number; |
| 30 | /** Width of the darkened sides, in CSS pixels. */ |
| 31 | shadeWidth: number; |
| 32 | /** How far the glow reaches in from the top and bottom rims, in CSS pixels. */ |
| 33 | glowReach: number; |
| 34 | /** Bottom glow strength relative to the top (0–1). */ |
| 35 | glowBottom: number; |
| 36 | /** |
| 37 | * Steepest the rim's shift may fall per pixel inward (0–1). Past 1 the lens folds |
| 38 | * content into mirrored echoes. Where it falls, the rim magnifies what is beneath |
| 39 | * by 1 / (1 - maxFall), so lower values bend more gently and keep text crossing |
| 40 | * the rim from smearing. |
| 41 | */ |
| 42 | maxFall: number; |
| 43 | /** |
| 44 | * Width of iOS 27's darkened edge, in CSS pixels: a contour where the glass meets |
| 45 | * the air, with the hairline moved just inside it. 0 leaves it out. |
| 46 | */ |
| 47 | edge: number; |
| 48 | /** |
| 49 | * Share of the hairline's light that gathers into glints where the light catches |
| 50 | * the curve, at the top-left and, fainter, the bottom-right (0–1). 0 keeps an even |
| 51 | * band along the top and bottom. |
| 52 | */ |
| 53 | glint: number; |
| 54 | }; |
| 55 |
| 56 | /** Proportions measured from Apple's iOS 27 press images, then tuned by eye. */ |
| 57 | export const DEFAULT_GEOMETRY: GlassGeometry = { |
| 58 | bezel: 17, |
| 59 | thickness: 24, |
| 60 | sideBezel: 27, |
| 61 | sideThickness: 44, |
| 62 | refractiveIndex: 1.5, |
| 63 | hairline: 1, |
| 64 | hairlineSides: 0.15, |
| 65 | shadeWidth: 1, |
| 66 | glowReach: 8, |
| 67 | glowBottom: 0.5, |
| 68 | // The margin below 1 keeps neighbouring samples apart after 8-bit quantisation of the map. |
| 69 | maxFall: 0.8, |
| 70 | edge: 0.75, |
| 71 | glint: 0.4, |
| 72 | }; |
| 73 |
| 74 | /** |
| 75 | * The rim for a lens about a control high, like a segmented control's thumb: the |
| 76 | * rounded ends bend over their whole radius, and gently, since at a capsule's |
| 77 | * magnification labels crossing the rim smear into streaks. Pair with the `lens` |
| 78 | * variant. |
| 79 | */ |
| 80 | export const LENS_GEOMETRY: GlassGeometry = { |
| 81 | ...DEFAULT_GEOMETRY, |
| 82 | bezel: 9, |
| 83 | thickness: 8, |
| 84 | sideBezel: 18, |
| 85 | sideThickness: 16, |
| 86 | glowReach: 5, |
| 87 | maxFall: 0.3, |
| 88 | glint: 0, |
| 89 | }; |
| 90 |
| 91 | /** Masked layers over the glass body: rim hairline, darkened sides, inner glow, outer contour. */ |
| 92 | export type GlassMask = "highlight" | "shade" | "glow" | "edge"; |
| 93 | export const MASKS: readonly GlassMask[] = ["highlight", "shade", "glow", "edge"]; |
| 94 |
| 95 | function smoothstep(from: number, to: number, value: number) { |
| 96 | const t = Math.min(1, Math.max(0, (value - from) / (to - from))); |
| 97 | return t * t * (3 - 2 * t); |
| 98 | } |
| 99 |
| 100 | /** Slope of the convex squircle y = (1 - (1 - x)^4)^(1/4), flat once x reaches 1. */ |
| 101 | function squircleSlope(x: number) { |
| 102 | const clamped = Math.min(Math.max(x, 1e-3), 1); |
| 103 | return (1 - clamped) ** 3 * (1 - (1 - clamped) ** 4) ** -0.75; |
| 104 | } |
| 105 |
| 106 | /** Lateral shift across one rim cross-section, from the outer edge inward. */ |
| 107 | function refractionProfile(bezel: number, thickness: number, refractiveIndex: number, maxFall: number) { |
| 108 | const profile = new Float32Array(PROFILE_STEPS + 1); |
| 109 | for (let step = 0; step <= PROFILE_STEPS; step++) { |
| 110 | const x = Math.max(step / PROFILE_STEPS, 1e-3); |
| 111 | const height = (1 - (1 - x) ** 4) ** 0.25; |
| 112 | // A straight-down view ray meets the tilted surface, refracts (Snell), and |
| 113 | // travels through the remaining depth to the backdrop. |
| 114 | const incidence = Math.atan((thickness / bezel) * squircleSlope(x)); |
| 115 | const refracted = Math.asin(Math.sin(incidence) / refractiveIndex); |
| 116 | profile[step] = thickness * height * Math.tan(incidence - refracted); |
| 117 | } |
| 118 | // Keep the lens one-to-one. Where the shift falls faster than the rim moves inward, |
| 119 | // neighbouring pixels sample in reverse order and content folds into mirrored |
| 120 | // echoes. Capping the fall compresses content toward the rim instead. |
| 121 | const fallPerStep = (maxFall * bezel) / PROFILE_STEPS; |
| 122 | for (let step = PROFILE_STEPS - 1; step >= 0; step--) { |
| 123 | profile[step] = Math.min(profile[step] ?? 0, (profile[step + 1] ?? 0) + fallPerStep); |
| 124 | } |
| 125 | let max = 0; |
| 126 | for (const shift of profile) max = Math.max(max, shift); |
| 127 | return { profile, max }; |
| 128 | } |
| 129 |
| 130 | export type GlassSlices = { |
| 131 | /** Corner radius the slices were built for, in whole CSS pixels. */ |
| 132 | radius: number; |
| 133 | /** Square side in device pixels: two corners plus one pixel of straight edge. */ |
| 134 | size: number; |
| 135 | pixelRatio: number; |
| 136 | /** feDisplacementMap input: red and green carry X and Y around 128. */ |
| 137 | refraction: Uint8ClampedArray; |
| 138 | /** Alpha masks for the layers over the body. */ |
| 139 | masks: Record<GlassMask, Uint8ClampedArray>; |
| 140 | /** feDisplacementMap scale that restores the map to pixels. */ |
| 141 | scale: number; |
| 142 | }; |
| 143 |
| 144 | /** |
| 145 | * Renders a rounded square of side 2r + 1. Its corners are the glass's corners and |
| 146 | * its centre row and column are its straight edges, so any size can be assembled |
| 147 | * from eight slices. |
| 148 | */ |
| 149 | export function createGlassSlices( |
| 150 | cornerRadius: number, |
| 151 | pixelRatio = 1, |
| 152 | geometry: Partial<GlassGeometry> = {}, |
| 153 | ): GlassSlices { |
| 154 | const { |
| 155 | bezel, |
| 156 | thickness, |
| 157 | sideBezel, |
| 158 | sideThickness, |
| 159 | refractiveIndex, |
| 160 | hairline, |
| 161 | hairlineSides, |
| 162 | shadeWidth, |
| 163 | glowReach, |
| 164 | glowBottom, |
| 165 | maxFall, |
| 166 | edge, |
| 167 | glint, |
| 168 | } = { ...DEFAULT_GEOMETRY, ...geometry }; |
| 169 | const radius = Math.max(1, Math.floor(cornerRadius)); |
| 170 | const side = radius * 2 + 1; |
| 171 | const size = side * pixelRatio; |
| 172 | // Like real glass, the rounded ends bend light far more than the long straight edges, |
| 173 | // which keep content crossing them straight. Each has its own lens profile, blended |
| 174 | // around the curve. |
| 175 | const rim = Math.min(bezel, radius); |
| 176 | const sideRim = Math.min(sideBezel, radius); |
| 177 | const edges = refractionProfile(rim, thickness, refractiveIndex, maxFall); |
| 178 | const ends = refractionProfile(sideRim, sideThickness, refractiveIndex, maxFall); |
| 179 | const max = Math.max(edges.max, ends.max); |
| 180 | const shiftAt = (lens: typeof edges, width: number, inset: number) => |
| 181 | inset < width ? (lens.profile[Math.round((inset / width) * PROFILE_STEPS)] ?? 0) : 0; |
| 182 | const refraction = new Uint8ClampedArray(size * size * 4); |
| 183 | const masks = { |
| 184 | highlight: new Uint8ClampedArray(size * size * 4), |
| 185 | shade: new Uint8ClampedArray(size * size * 4), |
| 186 | glow: new Uint8ClampedArray(size * size * 4), |
| 187 | edge: new Uint8ClampedArray(size * size * 4), |
| 188 | }; |
| 189 | const half = side / 2; |
| 190 | const straight = half - radius; |
| 191 | // With a darkened edge, the hairline starts just inside it. |
| 192 | const lit = edge * 0.6; |
| 193 |
| 194 | for (let row = 0; row < size; row++) { |
| 195 | for (let column = 0; column < size; column++) { |
| 196 | const x = (column + 0.5) / pixelRatio - half; |
| 197 | const y = (row + 0.5) / pixelRatio - half; |
| 198 | // Signed distance to the rounded square; `inset` is depth inside its edge. |
| 199 | const qx = Math.abs(x) - straight; |
| 200 | const qy = Math.abs(y) - straight; |
| 201 | const cornerX = Math.max(qx, 0); |
| 202 | const cornerY = Math.max(qy, 0); |
| 203 | const corner = Math.hypot(cornerX, cornerY); |
| 204 | const inset = radius - corner - Math.min(Math.max(qx, qy), 0); |
| 205 | const nx = (corner > 0 ? cornerX / corner : qx > qy ? 1 : 0) * (Math.sign(x) || 1); |
| 206 | const ny = (corner > 0 ? cornerY / corner : qx > qy ? 0 : 1) * (Math.sign(y) || 1); |
| 207 | const index = (row * size + column) * 4; |
| 208 |
| 209 | let dx = 0; |
| 210 | let dy = 0; |
| 211 | if (inset > 0 && max > 0) { |
| 212 | const sideShare = nx * nx; |
| 213 | const shift = shiftAt(edges, rim, inset) * (1 - sideShare) + shiftAt(ends, sideRim, inset) * sideShare; |
| 214 | // Convex glass bends rays toward the centre, so the rim shows backdrop from further in. |
| 215 | dx = (-nx * shift) / max; |
| 216 | dy = (-ny * shift) / max; |
| 217 | } |
| 218 | refraction[index] = 128 + dx * 127; |
| 219 | refraction[index + 1] = 128 + dy * 127; |
| 220 | refraction[index + 2] = 128; |
| 221 | refraction[index + 3] = 255; |
| 222 |
| 223 | if (inset <= 0) continue; |
| 224 | // Proportions measured from Apple's iOS 27 press images: every edge feature is |
| 225 | // about a point wide, and only the glow reaches further in. |
| 226 | const cover = Math.min(1, inset * pixelRatio + 0.5); |
| 227 | const vertical = ny * ny; |
| 228 | // A one-point hairline where the rim meets the air, lit from above and below. |
| 229 | // Glints gather part of it where a light from the top-left catches the curve. |
| 230 | const bands = hairlineSides + (1 - hairlineSides) * Math.abs(ny) ** 1.5; |
| 231 | const facing = (-nx - ny) * Math.SQRT1_2; |
| 232 | const glints = Math.max(facing, 0) ** 3 + GLINT_RETURN * Math.max(-facing, 0) ** 3; |
| 233 | const reach = edge > 0 ? smoothstep(lit * 0.5, lit * 1.5, inset) : 1; |
| 234 | masks.highlight[index + 3] = 255 * cover * reach |
| 235 | * (1 - smoothstep(lit + 0.6 * hairline, lit + 1.6 * hairline, inset)) |
| 236 | * Math.max(bands * (1 - glint), Math.min(1, glints * glint * 2.5)); |
| 237 | // iOS 27's darkened edge: a contour where the glass meets the air, all the way round. |
| 238 | if (edge > 0) masks.edge[index + 3] = 255 * cover * (1 - smoothstep(0.5 * edge, 1.1 * edge, inset)); |
| 239 | // The darkened edge, on the sides only and about a point and a half wide. |
| 240 | masks.shade[index + 3] = 255 * cover * (1 - smoothstep(0.8 * shadeWidth, 2 * shadeWidth, inset)) * nx * nx; |
| 241 | // Light gathered just inside the top and bottom rims, fading over about five points. |
| 242 | masks.glow[index + 3] = 255 * cover * smoothstep(0.8, 1.6, inset) * (1 - smoothstep(1.6, Math.max(glowReach, 1.7), inset)) |
| 243 | * vertical * (ny < 0 ? 1 : glowBottom); |
| 244 | } |
| 245 | } |
| 246 | // feDisplacementMap moves by scale * (channel - 0.5); a full swing is ±127/255. |
| 247 | return { radius, size, pixelRatio, refraction, masks, scale: (max * 255) / 127 }; |
| 248 | } |
| 249 |
| 250 | /** Device-pixel rectangles of the eight slices, in SLICES order. */ |
| 251 | export function sliceRects(slices: Pick<GlassSlices, "radius" | "pixelRatio">) { |
| 252 | const corner = slices.radius * slices.pixelRatio; |
| 253 | const edge = slices.pixelRatio; |
| 254 | const far = corner + edge; |
| 255 | return [ |
| 256 | [0, 0, corner, corner], [far, 0, corner, corner], [0, far, corner, corner], [far, far, corner, corner], |
| 257 | [corner, 0, edge, corner], [corner, far, edge, corner], [0, corner, corner, edge], [far, corner, corner, edge], |
| 258 | ] as const; |
| 259 | } |
| 260 |