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.
| 6 | const centre = 27; |
| 7 |
| 8 | function refraction(x: number, y: number) { |
| 9 | const index = (y * slices.size + x) * 4; |
| 10 | return { r: slices.refraction[index], g: slices.refraction[index + 1] }; |
| 11 | } |
| 12 |
| 13 | function alpha(layer: keyof typeof slices.masks, x: number, y: number) { |
| 14 | return slices.masks[layer][(y * slices.size + x) * 4 + 3] ?? 0; |
| 15 | } |
| 16 |
| 17 | describe("createGlassSlices", () => { |
| 18 | it("renders two corners and one pixel of straight edge", () => { |
| 19 | expect(slices.size).toBe(55); |
| 20 | expect(createGlassSlices(27, 2).size).toBe(110); |
| 21 | }); |
| 22 |
| 23 | it("leaves the flat centre undistorted so labels stay sharp", () => { |
| 24 | expect(refraction(centre, centre)).toEqual({ r: 128, g: 128 }); |
| 25 | }); |
| 26 |
| 27 | it("bends the rim toward the centre", () => { |
| 28 | expect(refraction(centre, 1).g).toBeGreaterThan(128); |
| 29 | expect(refraction(centre, 53).g).toBeLessThan(128); |
| 30 | expect(refraction(1, centre).r).toBeGreaterThan(128); |
| 31 | expect(refraction(53, centre).r).toBeLessThan(128); |
| 32 | }); |
| 33 |
| 34 | it("never folds content back on itself across the rim", () => { |
| 35 | // Default lens. Each row inward must sample the backdrop further in than the last, |
| 36 | // or neighbouring pixels swap order and content shows up as mirrored echoes. |
| 37 | const lens = createGlassSlices(27, 1); |
| 38 | let previous = -Infinity; |
| 39 | for (let row = 0; row <= 27; row++) { |
| 40 | const green = lens.refraction[(row * lens.size + 27) * 4 + 1] ?? 128; |
| 41 | const sampled = row + 0.5 + ((green - 127.5) / 255) * lens.scale; |
| 42 | expect(sampled).toBeGreaterThan(previous); |
| 43 | previous = sampled; |
| 44 | } |
| 45 | }); |
| 46 |
| 47 | it("bends the rounded ends far more than the straight edges, still without folding", () => { |
| 48 | const lens = createGlassSlices(27, 1); |
| 49 | const shiftAt = (x: number, y: number, channel: 0 | 1) => |
| 50 | (((lens.refraction[(y * lens.size + x) * 4 + channel] ?? 128) - 127.5) / 255) * lens.scale; |
| 51 | // Six pixels in: across the top edge versus into the side. |
| 52 | expect(shiftAt(6, 27, 0)).toBeGreaterThan(shiftAt(27, 6, 1) * 2); |
| 53 | let previous = -Infinity; |
| 54 | for (let column = 0; column <= 27; column++) { |
| 55 | const sampled = column + 0.5 + shiftAt(column, 27, 0); |
| 56 | expect(sampled).toBeGreaterThan(previous); |
| 57 | previous = sampled; |
| 58 | } |
| 59 | }); |
| 60 |
| 61 | it("keeps pixels outside the rounded corner neutral and unmasked", () => { |
| 62 | expect(refraction(0, 0)).toEqual({ r: 128, g: 128 }); |
| 63 | expect(alpha("highlight", 0, 0)).toBe(0); |
| 64 | }); |
| 65 |
| 66 | it("keeps every edge feature about a point wide", () => { |
| 67 | expect(alpha("highlight", centre, 0)).toBeGreaterThan(200); |
| 68 | expect(alpha("highlight", centre, 3)).toBe(0); |
| 69 | expect(alpha("shade", 0, centre)).toBeGreaterThan(200); |
| 70 | expect(alpha("shade", 3, centre)).toBe(0); |
| 71 | }); |
| 72 |
| 73 | it("lights the top and bottom rims and darkens only the sides", () => { |
| 74 | expect(alpha("highlight", centre, 0)).toBeGreaterThan(alpha("highlight", 0, centre) * 4); |
| 75 | expect(alpha("shade", centre, 0)).toBe(0); |
| 76 | }); |
| 77 |
| 78 | it("glows just inside the top rim more than the bottom, fading by its reach", () => { |
| 79 | expect(alpha("glow", centre, 2)).toBeGreaterThan(alpha("glow", centre, 52)); |
| 80 | expect(alpha("glow", centre, DEFAULT_GEOMETRY.glowReach + 1)).toBe(0); |
| 81 | }); |
| 82 |
| 83 | it("draws the darkened edge as a contour all the way round, and none without one", () => { |
| 84 | const lens = createGlassSlices(27, 1); |
| 85 | const edge = (x: number, y: number) => lens.masks.edge[(y * lens.size + x) * 4 + 3] ?? 0; |
| 86 | expect(edge(centre, 0)).toBeGreaterThan(150); |
| 87 | expect(edge(0, centre)).toBeGreaterThan(150); |
| 88 | expect(edge(centre, 2)).toBe(0); |
| 89 | expect(slices.masks.edge.every(value => value === 0)).toBe(true); |
| 90 | }); |
| 91 |
| 92 | it("gathers the hairline into a glint at the top-left, fainter at the bottom-right", () => { |
| 93 | const lens = createGlassSlices(27, 1, { edge: 0 }); |
| 94 | const highlight = (x: number, y: number) => lens.masks.highlight[(y * lens.size + x) * 4 + 3] ?? 0; |
| 95 | // Points on the rim at 45° in each corner. |
| 96 | const topLeft = highlight(8, 8); |
| 97 | const bottomRight = highlight(46, 46); |
| 98 | const topRight = highlight(46, 8); |
| 99 | expect(topLeft).toBeGreaterThan(bottomRight); |
| 100 | expect(bottomRight).toBeGreaterThan(topRight); |
| 101 | }); |
| 102 |
| 103 | it("bends a gentle lens less steeply at the rim", () => { |
| 104 | const steepest = (maxFall: number) => { |
| 105 | const lens = createGlassSlices(27, 1, { maxFall }); |
| 106 | let fall = 0; |
| 107 | for (let row = 0; row < 27; row++) { |
| 108 | const at = (y: number) => lens.refraction[(y * lens.size + 27) * 4 + 1] ?? 128; |
| 109 | fall = Math.max(fall, ((at(row) - at(row + 1)) / 255) * lens.scale); |
| 110 | } |
| 111 | return fall; |
| 112 | }; |
| 113 | expect(steepest(0.3)).toBeLessThan(steepest(0.8)); |
| 114 | expect(steepest(0.3)).toBeLessThanOrEqual(0.3 + 0.1); |
| 115 | }); |
| 116 | }); |
| 117 |
| 118 | describe("sliceRects", () => { |
| 119 | it("covers the square without overlap", () => { |
| 120 | const covered = sliceRects({ radius: 2, pixelRatio: 1 }).reduce((sum, [, , w, h]) => sum + w * h, 0); |
| 121 | // A 5px square: everything but the one-pixel centre. |
| 122 | expect(covered).toBe(24); |
| 123 | }); |
| 124 | }); |
| 125 |