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.
| 8 | type GlassMask, |
| 9 | type GlassSlices, |
| 10 | } from "./lens"; |
| 11 | import { |
| 12 | DEFAULT_HUE_STRENGTH, |
| 13 | DEFAULT_TINT, |
| 14 | hueMatrix, |
| 15 | resolveTone, |
| 16 | toneFilters, |
| 17 | toneWash, |
| 18 | type GlassAppearance, |
| 19 | type GlassFilter, |
| 20 | type GlassToneOverrides, |
| 21 | type GlassVariant, |
| 22 | } from "./tone"; |
| 23 |
| 24 | /* Liquid Glass after iOS 27, without a frosted fill. Every layer is the page behind |
| 25 | the glass, filtered: a body that diffuses it, then darkened sides, an inner glow, |
| 26 | a darkened outer edge and a rim hairline gathered into glints, each masked to a |
| 27 | point-wide band of the lens. Rim refraction |
| 28 | needs SVG filters in backdrop-filter, which only Chromium renders; WebKit and |
| 29 | Gecko would drop the whole backdrop, so they keep the glass without the bend. |
| 30 | Filters are set inline as literal values because Safari ignores custom |
| 31 | properties in -webkit-backdrop-filter. */ |
| 32 |
| 33 | const SVG_NS = "http://www.w3.org/2000/svg"; |
| 34 | // A resizing radius (a springing capsule, an opening menu) rebuilds at most this often. |
| 35 | const REBUILD_INTERVAL = 100; |
| 36 | // How long the pixel ratio must hold still before a zoom counts as finished. |
| 37 | const ZOOM_SETTLE = 150; |
| 38 | const LAYERS = ["body", "shade", "glow", "edge", "highlight"] as const; |
| 39 | type GlassLayer = (typeof LAYERS)[number]; |
| 40 |
| 41 | /** |
| 42 | * How the glass covers its surface: |
| 43 | * - `box`: fills the surface. For surfaces that don't scroll themselves. |
| 44 | * - `fixed`: fills the nearest containing block for fixed elements, such as a |
| 45 | * transformed popper wrapper, so the glass holds still while the surface |
| 46 | * scrolls its own content. |
| 47 | * - `scroll`: spans the surface's whole scroll height, so a surface that scrolls |
| 48 | * itself (a dialog, a sheet) stays glass all the way down. |
| 49 | */ |
| 50 | export type LiquidGlassFit = "box" | "fixed" | "scroll"; |
| 51 |
| 52 | export type LiquidGlassOptions = { |
| 53 | /** |
| 54 | * - `system` (default): follows prefers-color-scheme. |
| 55 | * - `class`: dark while `<html>` has the `dark` class, as with Tailwind's class |
| 56 | * strategy and next-themes. |
| 57 | */ |
| 58 | appearance?: GlassAppearance | "system" | "class" | undefined; |
| 59 | variant?: GlassVariant | undefined; |
| 60 | /** Per-appearance overrides of the variant's tone. */ |
| 61 | tone?: GlassToneOverrides | undefined; |
| 62 | geometry?: Partial<GlassGeometry> | undefined; |
| 63 | /** Colour washed over the body, by the tone's `tint` share. Any CSS colour, including var(). */ |
| 64 | tint?: string | undefined; |
| 65 | /** A colour whose hue the glass takes on, like tinted iOS glass. Any concrete CSS colour. */ |
| 66 | hue?: string | null | undefined; |
| 67 | /** How much of the hue the glass takes (0–1). Defaults to 1 on light glass and 0.5 on dark. */ |
| 68 | hueStrength?: number | undefined; |
| 69 | fit?: LiquidGlassFit | undefined; |
| 70 | /** Bend the page at the rim, where the browser supports it. On by default. */ |
| 71 | refraction?: boolean | undefined; |
| 72 | /** |
| 73 | * Refraction samples per pixel: 1, or 4 to antialias the bend. Chromium's |
| 74 | * feDisplacementMap takes the nearest backdrop pixel, so wherever the glass |
| 75 | * stretches or compresses what is beneath, edges stair-step and crawl as it |
| 76 | * moves. Four samples a quarter of a device pixel apart smooth them, while flat |
| 77 | * glass, which shifts nothing, stays sharp. They cost four displacement passes, |
| 78 | * so they suit small glass with nothing blurring its backdrop, like a lens. |
| 79 | */ |
| 80 | samples?: 1 | 4 | undefined; |
| 81 | /** |
| 82 | * For menus, popovers, dialogs and sheets. Floating glass diffuses the page more, |
| 83 | * as iOS 27's does, so text beneath its own text washes into soft colour instead |
| 84 | * of reading through, and the surface casts a drop shadow (--liquid-glass-shadow). |
| 85 | */ |
| 86 | floating?: boolean | undefined; |
| 87 | /** |
| 88 | * For glass that holds controls, like SwiftUI's `.glassEffect(.regular.interactive())`: |
| 89 | * the pointer warms the glass beneath it, and a press blooms out from there. |
| 90 | */ |
| 91 | interactive?: boolean | undefined; |
| 92 | }; |
| 93 |
| 94 | export type GlassTiming = { |
| 95 | /** Milliseconds. */ |
| 96 | duration?: number | undefined; |
| 97 | easing?: string | undefined; |
| 98 | delay?: number | undefined; |
| 99 | /** |
| 100 | * Grow the glass from (or shrink it to) this scale of the surface, for surfaces |
| 101 | * that zoom as they open and close. The glass changes size through its inset, |
| 102 | * since scaling it blanks it; scale the surface's contents, not the surface. |
| 103 | */ |
| 104 | scale?: number | undefined; |
| 105 | }; |
| 106 |
| 107 | export type LiquidGlass = { |
| 108 | /** The glass element: the first child of the surface. */ |
| 109 | readonly element: HTMLElement; |
| 110 | /** The resolved appearance. */ |
| 111 | readonly appearance: GlassAppearance; |
| 112 | update(options: LiquidGlassOptions): void; |
| 113 | /** |
| 114 | * Magnifies what is beneath the glass about its centre by `factor` (1 for none), |
| 115 | * as a raised lens does. Cheap enough to call every frame. Needs refraction. |
| 116 | */ |
| 117 | magnify(factor: number): void; |
| 118 | /** Grows the glass from clear to its tone, for surfaces that appear. */ |
| 119 | materialize(timing?: GlassTiming): Promise<void>; |
| 120 | /** Fades the glass back to clear and holds it there, for surfaces that leave. */ |
| 121 | dematerialize(timing?: GlassTiming): Promise<void>; |
| 122 | destroy(): void; |
| 123 | }; |
| 124 |
| 125 | // ── Slices ────────────────────────────────────────────── |
| 126 |
| 127 | function sliceUrls(pixels: Uint8ClampedArray, slices: GlassSlices) { |
| 128 | const source = document.createElement("canvas"); |
| 129 | source.width = slices.size; |
| 130 | source.height = slices.size; |
| 131 | source.getContext("2d")?.putImageData(new ImageData(new Uint8ClampedArray(pixels), slices.size, slices.size), 0, 0); |
| 132 | const piece = document.createElement("canvas"); |
| 133 | const context = piece.getContext("2d"); |
| 134 | return sliceRects(slices).map(([x, y, width, height]) => { |
| 135 | piece.width = width; |
| 136 | piece.height = height; |
| 137 | context?.drawImage(source, x, y, width, height, 0, 0, width, height); |
| 138 | return piece.toDataURL(); |
| 139 | }); |
| 140 | } |
| 141 |
| 142 | type BuiltGlass = { |
| 143 | slices: GlassSlices; |
| 144 | masks: Record<GlassMask, string[]>; |
| 145 | refraction: string[]; |
| 146 | /** Resolves once every slice is decoded; see decodeSlices. */ |
| 147 | decoded: Promise<void>; |
| 148 | }; |
| 149 |
| 150 | // Chromium paints a masked layer without its mask until the mask image has |
| 151 | // decoded, and the rim layers brighten everything they cover, so a fresh mask |
| 152 | // flashes the whole surface light. Slices are decoded before use and the images |
| 153 | // kept for as long as the build is cached, so masks and feImages that name these |
| 154 | // URLs find them ready. |
| 155 | const decodedImages = new WeakMap<BuiltGlass, HTMLImageElement[]>(); |
| 156 |
| 157 | function decodeSlices(built: Omit<BuiltGlass, "decoded">) { |
| 158 | const images = [...MASKS.flatMap(mask => built.masks[mask]), ...built.refraction].map(url => { |
| 159 | const image = new Image(); |
| 160 | image.src = url; |
| 161 | return image; |
| 162 | }); |
| 163 | return { images, decoded: Promise.all(images.map(image => image.decode().catch(() => {}))).then(() => {}) }; |
| 164 | } |
| 165 |
| 166 | // A build is a pure function of radius, pixel ratio and geometry, and surfaces flip |
| 167 | // between a few sizes (full, compact, open), so repeat builds come from here instead |
| 168 | // of re-rendering the lens and re-encoding 32 slices mid-animation. |
| 169 | const builds = new Map<string, BuiltGlass>(); |
| 170 | const BUILD_CACHE_SIZE = 12; |
| 171 |
| 172 | function buildGlass(radius: number, pixelRatio: number, geometry: Partial<GlassGeometry>) { |
| 173 | const key = `${radius}:${pixelRatio}:${JSON.stringify(geometry)}`; |
| 174 | const cached = builds.get(key); |
| 175 | if (cached) { |
| 176 | // Most recently used last, so eviction takes the build that has gone longest unused. |
| 177 | builds.delete(key); |
| 178 | builds.set(key, cached); |
| 179 | return cached; |
| 180 | } |
| 181 | const slices = createGlassSlices(radius, pixelRatio, geometry); |
| 182 | const urls = { |
| 183 | slices, |
| 184 | masks: { |
| 185 | highlight: sliceUrls(slices.masks.highlight, slices), |
| 186 | shade: sliceUrls(slices.masks.shade, slices), |
| 187 | glow: sliceUrls(slices.masks.glow, slices), |
| 188 | // Glass without a darkened edge has no edge layer to mask. |
| 189 | edge: (geometry.edge ?? DEFAULT_GEOMETRY.edge) > 0 ? sliceUrls(slices.masks.edge, slices) : [], |
| 190 | }, |
| 191 | refraction: sliceUrls(slices.refraction, slices), |
| 192 | }; |
| 193 | const { images, decoded } = decodeSlices(urls); |
| 194 | const built: BuiltGlass = { ...urls, decoded }; |
| 195 | decodedImages.set(built, images); |
| 196 | builds.set(key, built); |
| 197 | if (builds.size > BUILD_CACHE_SIZE) builds.delete(builds.keys().next().value ?? ""); |
| 198 | return built; |
| 199 | } |
| 200 |
| 201 | /** CSS mask layers that assemble the slices over any box. */ |
| 202 | function maskStyles(urls: string[], radius: number) { |
| 203 | const r = `${radius}px`; |
| 204 | const span = `calc(100% - ${radius * 2}px)`; |
| 205 | return { |
| 206 | image: urls.map(url => `url("${url}")`).join(", "), |
| 207 | position: `0 0, 100% 0, 0 100%, 100% 100%, ${r} 0, ${r} 100%, 0 ${r}, 100% ${r}`, |
| 208 | size: `${r} ${r}, ${r} ${r}, ${r} ${r}, ${r} ${r}, ${span} ${r}, ${span} ${r}, ${r} ${span}, ${r} ${span}`, |
| 209 | }; |
| 210 | } |
| 211 |
| 212 | // ── Filters ───────────────────────────────────────────── |
| 213 |
| 214 | /** True where SVG filters work inside backdrop-filter. */ |
| 215 | export function supportsRefraction() { |
| 216 | if (typeof navigator === "undefined") return false; |
| 217 | const brands = (navigator as Navigator & { userAgentData?: { brands: { brand: string }[] } }) |
| 218 | .userAgentData?.brands; |
| 219 | if (brands) return brands.some(({ brand }) => brand === "Chromium"); |
| 220 | // userAgentData only exists on secure origins; plain-http pages fall back to the |
| 221 | // user agent string. Chrome on iOS is WebKit and says "CriOS", not "Chrome/". |
| 222 | return /Chrome\//.test(navigator.userAgent); |
| 223 | } |
| 224 |
| 225 | let instances = 0; |
| 226 | function uniqueId(prefix: string) { |
| 227 | instances += 1; |
| 228 | return `${prefix}-${instances.toString(36)}${Math.random().toString(36).slice(2, 7)}`; |
| 229 | } |
| 230 |
| 231 | function hiddenSvg() { |
| 232 | const svg = document.createElementNS(SVG_NS, "svg"); |
| 233 | svg.setAttribute("aria-hidden", "true"); |
| 234 | svg.setAttribute("width", "0"); |
| 235 | svg.setAttribute("height", "0"); |
| 236 | // display: none would disable the filter; keep it rendered but out of flow. |
| 237 | svg.style.position = "absolute"; |
| 238 | svg.style.pointerEvents = "none"; |
| 239 | return svg; |
| 240 | } |
| 241 |
| 242 | // Device pixels from the pixel centre to each of the four refraction samples. |
| 243 | const SAMPLE_OFFSET = 0.25; |
| 244 |
| 245 | let magnificationField: { url: string; decoded: Promise<void>; image: HTMLImageElement } | null = null; |
| 246 |
| 247 | /** |
| 248 | * A map whose red and green run from 0 to 1 across and down it. Stretched over |
| 249 | * the glass, it is a displacement that grows linearly from the centre: a lens. |
| 250 | * Decoded before use, like the slices, and kept for the page's lifetime. |
| 251 | */ |
| 252 | function magnificationRamp() { |
| 253 | if (magnificationField) return magnificationField; |
| 254 | const size = 256; |
| 255 | const pixels = new Uint8ClampedArray(size * size * 4); |
| 256 | for (let row = 0; row < size; row++) { |
| 257 | for (let column = 0; column < size; column++) { |
| 258 | const index = (row * size + column) * 4; |
| 259 | pixels[index] = column; |
| 260 | pixels[index + 1] = row; |
| 261 | pixels[index + 2] = 128; |
| 262 | pixels[index + 3] = 255; |
| 263 | } |
| 264 | } |
| 265 | const canvas = document.createElement("canvas"); |
| 266 | canvas.width = size; |
| 267 | canvas.height = size; |
| 268 | canvas.getContext("2d")?.putImageData(new ImageData(pixels, size, size), 0, 0); |
| 269 | const url = canvas.toDataURL(); |
| 270 | const image = new Image(); |
| 271 | image.src = url; |
| 272 | magnificationField = { url, image, decoded: image.decode().catch(() => {}) }; |
| 273 | return magnificationField; |
| 274 | } |
| 275 |
| 276 | /** |
| 277 | * An SVG filter that assembles refraction slices into a displacement map, |
| 278 | * adds a magnifying field over the whole box while magnifying, and displaces |
| 279 | * the backdrop by it with one sample per pixel or four. |
| 280 | */ |
| 281 | function createRefractionFilter(id: string, samples: 1 | 4) { |
| 282 | const svg = hiddenSvg(); |
| 283 | const filter = document.createElementNS(SVG_NS, "filter"); |
| 284 | filter.id = id; |
| 285 | filter.setAttribute("x", "0"); |
| 286 | filter.setAttribute("y", "0"); |
| 287 | filter.setAttribute("filterUnits", "userSpaceOnUse"); |
| 288 | filter.setAttribute("primitiveUnits", "userSpaceOnUse"); |
| 289 | filter.setAttribute("color-interpolation-filters", "sRGB"); |
| 290 | // Uncovered map pixels stay neutral rather than displacing by half the scale. |
| 291 | const neutral = document.createElementNS(SVG_NS, "feFlood"); |
| 292 | neutral.setAttribute("flood-color", "rgb(128, 128, 128)"); |
| 293 | neutral.setAttribute("result", "neutral"); |
| 294 | const merge = document.createElementNS(SVG_NS, "feMerge"); |
| 295 | merge.setAttribute("result", "map"); |
| 296 | const neutralNode = document.createElementNS(SVG_NS, "feMergeNode"); |
| 297 | neutralNode.setAttribute("in", "neutral"); |
| 298 | const images = SLICES.map(name => { |
| 299 | const image = document.createElementNS(SVG_NS, "feImage"); |
| 300 | image.setAttribute("preserveAspectRatio", "none"); |
| 301 | image.setAttribute("result", `slice-${name}`); |
| 302 | return image; |
| 303 | }); |
| 304 | const mergeNodes = SLICES.map(name => { |
| 305 | const node = document.createElementNS(SVG_NS, "feMergeNode"); |
| 306 | node.setAttribute("in", `slice-${name}`); |
| 307 | return node; |
| 308 | }); |
| 309 | const primitive = (name: string, attributes: Record<string, string>) => { |
| 310 | const node = document.createElementNS(SVG_NS, name); |
| 311 | for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); |
| 312 | return node; |
| 313 | }; |
| 314 | // Magnifying, the rim's map ("rim") and the field scaled to the magnification |
| 315 | // ("field") add up to the map. Both share the displacement's one scale. |
| 316 | const field = primitive("feImage", { x: "0", y: "0", preserveAspectRatio: "none", result: "field-ramp" }); |
| 317 | const fieldScale = primitive("feColorMatrix", { in: "field-ramp", type: "matrix", result: "field" }); |
| 318 | const combine = primitive("feComposite", { in: "rim", in2: "field", operator: "arithmetic", k1: "0", k3: "1", result: "map" }); |
| 319 | // Magnified, what is beneath is resampled and comes out soft, worst on 1x |
| 320 | // screens; a light sharpen, growing with the magnification, restores it. |
| 321 | const sharpen = primitive("feConvolveMatrix", { order: "3", divisor: "1", preserveAlpha: "true", edgeMode: "duplicate" }); |
| 322 | // Four samples read the map shifted a quarter of a device pixel each way and |
| 323 | // average what they displace. |
| 324 | const four = samples === 4; |
| 325 | const shifts = four |
| 326 | ? [0, 1, 2, 3].map(index => primitive("feColorMatrix", { in: "map", type: "matrix", result: `map-${index}` })) |
| 327 | : []; |
| 328 | const displacements = (four ? [0, 1, 2, 3] : [0]).map(index => |
| 329 | primitive("feDisplacementMap", { |
| 330 | in: "SourceGraphic", |
| 331 | in2: four ? `map-${index}` : "map", |
| 332 | xChannelSelector: "R", |
| 333 | yChannelSelector: "G", |
| 334 | result: `sample-${index}`, |
| 335 | }), |
| 336 | ); |
| 337 | const average = (a: string, b: string, result?: string) => |
| 338 | primitive("feComposite", { in: a, in2: b, operator: "arithmetic", k2: "0.5", k3: "0.5", ...(result ? { result } : {}) }); |
| 339 | const averages = four |
| 340 | ? [average("sample-0", "sample-1", "pair-0"), average("sample-2", "sample-3", "pair-1"), average("pair-0", "pair-1")] |
| 341 | : []; |
| 342 | svg.append(filter); |
| 343 | document.body.append(svg); |
| 344 |
| 345 | let rimScale = 0; |
| 346 | let magnification = 1; |
| 347 | let fieldRequested = false; |
| 348 | let fieldReady = false; |
| 349 | let width = 0; |
| 350 | let height = 0; |
| 351 | let removed = false; |
| 352 | const magnifying = () => fieldReady && magnification > 1.0005; |
| 353 |
| 354 | // The filter runs every frame the page moves beneath the glass, so it only carries |
| 355 | // the primitives the current box needs. A capsule's corners and top and bottom edges |
| 356 | // tile it completely, leaving the side slices empty and the neutral fill unseen. |
| 357 | let structure = ""; |
| 358 | let used: boolean[] = []; |
| 359 | let covered = false; |
| 360 | const restructure = () => { |
| 361 | const lens = magnifying(); |
| 362 | // Where slices meet off the pixel grid, the map is partly transparent. |
| 363 | // Displacement reads it unpremultiplied and doesn't mind, but adding the |
| 364 | // field would shift that row, so a magnifying lens keeps the neutral fill. |
| 365 | const flood = !covered || lens; |
| 366 | const key = `${used.join()}:${flood}:${lens}`; |
| 367 | if (key === structure) return; |
| 368 | structure = key; |
| 369 | const keep = <Item,>(items: Item[]) => items.filter((_, index) => used[index]); |
| 370 | merge.setAttribute("result", lens ? "rim" : "map"); |
| 371 | merge.replaceChildren(...(flood ? [neutralNode] : []), ...keep(mergeNodes)); |
| 372 | filter.replaceChildren( |
| 373 | ...(flood ? [neutral] : []), |
| 374 | ...keep(images), |
| 375 | merge, |
| 376 | ...(lens ? [field, fieldScale, combine] : []), |
| 377 | ...displacements.flatMap((displacement, index) => [...(shifts[index] ? [shifts[index]] : []), displacement]), |
| 378 | ...averages, |
| 379 | ...(lens ? [sharpen] : []), |
| 380 | ); |
| 381 | }; |
| 382 |
| 383 | // Writes the values that follow the box, the magnification and the pixel ratio. |
| 384 | const update = () => { |
| 385 | // Magnifying by m, a point d from the centre shows the backdrop d / m from it. |
| 386 | const pull = magnifying() ? 1 - 1 / magnification : 0; |
| 387 | const scale = rimScale + pull * Math.max(width, height); |
| 388 | for (const displacement of displacements) displacement.setAttribute("scale", scale.toFixed(3)); |
| 389 | if (magnifying() && scale > 0) { |
| 390 | const share = rimScale / scale; |
| 391 | combine.setAttribute("k2", share.toFixed(5)); |
| 392 | combine.setAttribute("k4", (-share / 2).toFixed(5)); |
| 393 | const x = (-pull * width) / scale; |
| 394 | const y = (-pull * height) / scale; |
| 395 | fieldScale.setAttribute("values", `${x} 0 0 0 ${0.5 - x / 2} 0 ${y} 0 0 ${0.5 - y / 2} 0 0 0 0 0.5 0 0 0 0 1`); |
| 396 | // Tuned by eye at 16%: 0.2 on 1x screens, less at 2x, where it is softened less. |
| 397 | const amount = (magnification - 1) * ((window.devicePixelRatio || 1) >= 1.5 ? 0.85 : 1.25); |
| 398 | sharpen.setAttribute("kernelMatrix", `0 ${-amount} 0 ${-amount} ${1 + 4 * amount} ${-amount} 0 ${-amount} 0`); |
| 399 | } |
| 400 | if (shifts.length && scale > 0) { |
| 401 | const offset = SAMPLE_OFFSET / (window.devicePixelRatio || 1) / scale; |
| 402 | const signs = [[1, 1], [-1, 1], [1, -1], [-1, -1]] as const; |
| 403 | shifts.forEach((shift, index) => { |
| 404 | const [sx, sy] = signs[index] ?? [0, 0]; |
| 405 | shift.setAttribute("values", `1 0 0 0 ${sx * offset} 0 1 0 0 ${sy * offset} 0 0 1 0 0 0 0 0 1 0`); |
| 406 | }); |
| 407 | } |
| 408 | }; |
| 409 | const refresh = () => { |
| 410 | restructure(); |
| 411 | update(); |
| 412 | }; |
| 413 |
| 414 | return { |
| 415 | setSlices(urls: string[], scale: number) { |
| 416 | images.forEach((image, index) => image.setAttribute("href", urls[index] ?? "")); |
| 417 | rimScale = scale; |
| 418 | update(); |
| 419 | }, |
| 420 | /** Places the slices over the current box; cheap enough for every resize. */ |
| 421 | fit(boxWidth: number, boxHeight: number, radius: number) { |
| 422 | width = boxWidth; |
| 423 | height = boxHeight; |
| 424 | const span = Math.max(0, width - radius * 2); |
| 425 | const tall = Math.max(0, height - radius * 2); |
| 426 | const rects = [ |
| 427 | [0, 0, radius, radius], [width - radius, 0, radius, radius], |
| 428 | [0, height - radius, radius, radius], [width - radius, height - radius, radius, radius], |
| 429 | [radius, 0, span, radius], [radius, height - radius, span, radius], |
| 430 | [0, radius, radius, tall], [width - radius, radius, radius, tall], |
| 431 | ]; |
| 432 | used = rects.map(([, , w = 0, h = 0]) => w > 0 && h > 0); |
| 433 | covered = tall === 0; |
| 434 | filter.setAttribute("width", String(width)); |
| 435 | filter.setAttribute("height", String(height)); |
| 436 | images.forEach((image, index) => { |
| 437 | const [x = 0, y = 0, w = 0, h = 0] = rects[index] ?? []; |
| 438 | image.setAttribute("x", String(x)); |
| 439 | image.setAttribute("y", String(y)); |
| 440 | image.setAttribute("width", String(w)); |
| 441 | image.setAttribute("height", String(h)); |
| 442 | }); |
| 443 | field.setAttribute("width", String(width)); |
| 444 | field.setAttribute("height", String(height)); |
| 445 | refresh(); |
| 446 | }, |
| 447 | /** Magnifies what is beneath the glass by `factor` (1 for none). */ |
| 448 | magnify(factor: number) { |
| 449 | if (factor > 1 && !fieldRequested) { |
| 450 | // The field is decoded before its first use, like the slices. |
| 451 | fieldRequested = true; |
| 452 | const ramp = magnificationRamp(); |
| 453 | field.setAttribute("href", ramp.url); |
| 454 | void ramp.decoded.then(() => { |
| 455 | if (removed) return; |
| 456 | fieldReady = true; |
| 457 | refresh(); |
| 458 | }); |
| 459 | } |
| 460 | if (Math.abs(factor - magnification) < 0.0005) return; |
| 461 | magnification = factor; |
| 462 | refresh(); |
| 463 | }, |
| 464 | remove() { |
| 465 | removed = true; |
| 466 | svg.remove(); |
| 467 | }, |
| 468 | }; |
| 469 | } |
| 470 |
| 471 | // Hue filters are shared: every surface with the same hue and strength names one. |
| 472 | let hueDefs: SVGSVGElement | null = null; |
| 473 | const hueFilters = new Map<string, string>(); |
| 474 |
| 475 | function hueFilterId(matrix: string) { |
| 476 | const existing = hueFilters.get(matrix); |
| 477 | if (existing && document.getElementById(existing)) return existing; |
| 478 | if (!hueDefs?.isConnected) { |
| 479 | hueDefs = hiddenSvg(); |
| 480 | document.body.append(hueDefs); |
| 481 | } |
| 482 | const id = uniqueId("liquid-glass-hue"); |
| 483 | const filter = document.createElementNS(SVG_NS, "filter"); |
| 484 | filter.id = id; |
| 485 | filter.setAttribute("color-interpolation-filters", "sRGB"); |
| 486 | const colour = document.createElementNS(SVG_NS, "feColorMatrix"); |
| 487 | colour.setAttribute("type", "matrix"); |
| 488 | colour.setAttribute("values", matrix); |
| 489 | filter.append(colour); |
| 490 | hueDefs.append(filter); |
| 491 | hueFilters.set(matrix, id); |
| 492 | return id; |
| 493 | } |
| 494 |
| 495 | let colourProbe: CanvasRenderingContext2D | null = null; |
| 496 |
| 497 | /** Any CSS colour as sRGB channels, by painting one pixel. */ |
| 498 | function parseColour(colour: string): [number, number, number] | null { |
| 499 | colourProbe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true }); |
| 500 | if (!colourProbe) return null; |
| 501 | colourProbe.canvas.width = 1; |
| 502 | colourProbe.canvas.height = 1; |
| 503 | colourProbe.clearRect(0, 0, 1, 1); |
| 504 | colourProbe.fillStyle = "#000"; |
| 505 | colourProbe.fillStyle = colour; |
| 506 | colourProbe.fillRect(0, 0, 1, 1); |
| 507 | const [r = 0, g = 0, b = 0] = colourProbe.getImageData(0, 0, 1, 1).data; |
| 508 | return [r, g, b]; |
| 509 | } |
| 510 |
| 511 | // ── Appearance ────────────────────────────────────────── |
| 512 |
| 513 | const DARK_QUERY = "(prefers-color-scheme: dark)"; |
| 514 |
| 515 | export function resolveAppearance(appearance: LiquidGlassOptions["appearance"]): GlassAppearance { |
| 516 | if (appearance === "dark" || appearance === "light") return appearance; |
| 517 | if (appearance === "class") return document.documentElement.classList.contains("dark") ? "dark" : "light"; |
| 518 | return matchMedia(DARK_QUERY).matches ? "dark" : "light"; |
| 519 | } |
| 520 |
| 521 | // One observer serves every surface that follows the root's class. |
| 522 | const classListeners = new Set<() => void>(); |
| 523 | let classObserver: MutationObserver | null = null; |
| 524 |
| 525 | function watchRootClass(listener: () => void) { |
| 526 | classListeners.add(listener); |
| 527 | if (!classObserver) { |
| 528 | classObserver = new MutationObserver(() => { |
| 529 | for (const notify of classListeners) notify(); |
| 530 | }); |
| 531 | classObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); |
| 532 | } |
| 533 | return () => { |
| 534 | classListeners.delete(listener); |
| 535 | if (classListeners.size === 0) { |
| 536 | classObserver?.disconnect(); |
| 537 | classObserver = null; |
| 538 | } |
| 539 | }; |
| 540 | } |
| 541 |
| 542 | /** Calls back whenever a `system` or `class` appearance may have flipped. */ |
| 543 | export function watchAppearance(listener: () => void) { |
| 544 | const system = matchMedia(DARK_QUERY); |
| 545 | system.addEventListener("change", listener); |
| 546 | const unwatchRootClass = watchRootClass(listener); |
| 547 | return () => { |
| 548 | system.removeEventListener("change", listener); |
| 549 | unwatchRootClass(); |
| 550 | }; |
| 551 | } |
| 552 |
| 553 | // ── Glass ─────────────────────────────────────────────── |
| 554 |
| 555 | function setFilter(node: HTMLElement, filter: GlassFilter, refraction?: string) { |
| 556 | node.style.setProperty("-webkit-backdrop-filter", filter.full); |
| 557 | node.style.setProperty( |
| 558 | "backdrop-filter", |
| 559 | refraction ? `${filter.full === "none" ? "" : `${filter.full} `}${refraction}` : filter.full, |
| 560 | ); |
| 561 | // The materialize keyframes in styles.css grow from one to the other. |
| 562 | node.style.setProperty("--liquid-glass-full", filter.full); |
| 563 | node.style.setProperty("--liquid-glass-clear", filter.clear); |
| 564 | } |
| 565 |
| 566 | /** |
| 567 | * Builds glass inside an existing element, which must be the first child of a |
| 568 | * positioned, rounded surface. The glass takes the surface's corner radius and sits |
| 569 | * behind its content. Layers the element already holds (`[data-layer]`) are kept, |
| 570 | * so server-rendered markup hydrates without a flash; missing ones are created. |
| 571 | */ |
| 572 | export function attachLiquidGlass(element: HTMLElement, initial: LiquidGlassOptions = {}): LiquidGlass { |
| 573 | const surface = element.parentElement; |
| 574 | if (!surface) throw new Error("Liquid glass needs a surface: attach it to a child of the rounded element."); |
| 575 | let options = initial; |
| 576 | element.classList.add("liquid-glass"); |
| 577 | element.setAttribute("aria-hidden", "true"); |
| 578 |
| 579 | const layers = {} as Record<GlassLayer, HTMLElement>; |
| 580 | let previous: HTMLElement | null = null; |
| 581 | for (const name of LAYERS) { |
| 582 | let node = element.querySelector<HTMLElement>(`:scope > [data-layer="${name}"]`); |
| 583 | if (!node) { |
| 584 | node = document.createElement("span"); |
| 585 | node.className = "liquid-glass-layer"; |
| 586 | node.dataset.layer = name; |
| 587 | if (previous) previous.after(node); |
| 588 | else element.prepend(node); |
| 589 | } |
| 590 | layers[name] = node; |
| 591 | previous = node; |
| 592 | } |
| 593 | let hueLayer: HTMLElement | null = null; |
| 594 | // Interactive glass's pointer light; see lightUp. |
| 595 | let light: { element: HTMLElement; remove: () => void } | null = null; |
| 596 |
| 597 | const filterId = uniqueId("liquid-glass-refraction"); |
| 598 | let refraction: ReturnType<typeof createRefractionFilter> | null = null; |
| 599 | // True once refraction slices are on screen; the body names the filter from then on. |
| 600 | let refracting = false; |
| 601 | let appearance: GlassAppearance = "dark"; |
| 602 | let animations: Animation[] = []; |
| 603 |
| 604 | const paint = () => { |
| 605 | const variant = options.variant ?? "regular"; |
| 606 | appearance = resolveAppearance(options.appearance); |
| 607 | const tone = resolveTone(variant, appearance, options.tone); |
| 608 | const filters = toneFilters(tone, options.floating); |
| 609 | const tint = options.tint ?? DEFAULT_TINT[appearance]; |
| 610 | const wash = toneWash(tone, tint); |
| 611 |
| 612 | setFilter(layers.body, filters.body, refracting ? `url(#${filterId})` : undefined); |
| 613 | layers.body.style.backgroundColor = wash; |
| 614 | layers.body.style.setProperty("--liquid-glass-wash", wash); |
| 615 | setFilter(layers.shade, filters.shade); |
| 616 | setFilter(layers.glow, filters.glow); |
| 617 | setFilter(layers.edge, filters.edge); |
| 618 | setFilter(layers.highlight, filters.highlight); |
| 619 |
| 620 | element.dataset.appearance = appearance; |
| 621 | element.dataset.variant = variant; |
| 622 | element.dataset.fit = options.fit ?? "box"; |
| 623 | surface.dataset.liquidGlass = appearance; |
| 624 | surface.toggleAttribute("data-liquid-glass-floating", Boolean(options.floating)); |
| 625 | // The solid fill that replaces the glass under prefers-reduced-transparency. |
| 626 | surface.style.setProperty("--liquid-glass-solid", tint); |
| 627 |
| 628 | const rgb = options.hue ? parseColour(options.hue) : null; |
| 629 | const matrix = rgb ? hueMatrix(rgb, options.hueStrength ?? DEFAULT_HUE_STRENGTH[appearance]) : null; |
| 630 | if (matrix) { |
| 631 | if (!hueLayer) { |
| 632 | hueLayer = document.createElement("span"); |
| 633 | hueLayer.className = "liquid-glass-hue"; |
| 634 | // Under the pointer light, which is added over everything else. |
| 635 | if (light) light.element.before(hueLayer); |
| 636 | else element.append(hueLayer); |
| 637 | } |
| 638 | const hue = `url(#${hueFilterId(matrix)})`; |
| 639 | hueLayer.style.setProperty("-webkit-backdrop-filter", hue); |
| 640 | hueLayer.style.setProperty("backdrop-filter", hue); |
| 641 | } else { |
| 642 | hueLayer?.remove(); |
| 643 | hueLayer = null; |
| 644 | } |
| 645 | }; |
| 646 |
| 647 | // ── Lens ── |
| 648 | const currentPixelRatio = () => Math.min(Math.ceil(devicePixelRatio), 2); |
| 649 | let pixelRatio = currentPixelRatio(); |
| 650 | // `requested` is the radius being built; `radius` is the one on screen, which |
| 651 | // trails it until the new slices have decoded. `shape` is the box's own |
| 652 | // radius: the slices stretch to it, so while the radius animates between |
| 653 | // rebuilds the rim keeps the box's shape, a little wider or narrower, |
| 654 | // instead of cutting across its corners. |
| 655 | let requested = 0; |
| 656 | let radius = 0; |
| 657 | let shape = 0; |
| 658 | let masked = 0; |
| 659 | let masks: BuiltGlass["masks"] | null = null; |
| 660 | let magnification = 1; |
| 661 | let generation = 0; |
| 662 | let box = ""; |
| 663 | let lastBuild = -Infinity; |
| 664 | let timer = 0; |
| 665 | let zoomTimer = 0; |
| 666 | let disposed = false; |
| 667 |
| 668 | const build = (target: number) => { |
| 669 | lastBuild = performance.now(); |
| 670 | requested = target; |
| 671 | const built = buildGlass(target, pixelRatio, options.geometry ?? {}); |
| 672 | const current = ++generation; |
| 673 | // The current slices stay up until the new ones can be shown masked. |
| 674 | void built.decoded.then(() => { |
| 675 | if (!disposed && current === generation) apply(built); |
| 676 | }); |
| 677 | }; |
| 678 |
| 679 | /** Lays the masks' slices over the box for a corner radius; `images` also swaps them in. */ |
| 680 | const placeMasks = (corner: number, images: boolean) => { |
| 681 | if (!masks) return; |
| 682 | masked = corner; |
| 683 | for (const mask of MASKS) { |
| 684 | // Unmasked, a layer would filter the whole box. |
| 685 | if (images) layers[mask].hidden = masks[mask].length === 0; |
| 686 | if (layers[mask].hidden) continue; |
| 687 | const styles = maskStyles(masks[mask], corner); |
| 688 | for (const prefix of ["-webkit-", ""]) { |
| 689 | if (images) layers[mask].style.setProperty(`${prefix}mask-image`, styles.image); |
| 690 | layers[mask].style.setProperty(`${prefix}mask-position`, styles.position); |
| 691 | layers[mask].style.setProperty(`${prefix}mask-size`, styles.size); |
| 692 | if (images) layers[mask].style.setProperty(`${prefix}mask-repeat`, "no-repeat"); |
| 693 | } |
| 694 | } |
| 695 | }; |
| 696 |
| 697 | const apply = (built: BuiltGlass) => { |
| 698 | radius = built.slices.radius; |
| 699 | masks = built.masks; |
| 700 | placeMasks(shape || radius, true); |
| 701 | if (refraction) { |
| 702 | refraction.setSlices(built.refraction, built.slices.scale); |
| 703 | if (element.offsetWidth && element.offsetHeight) { |
| 704 | refraction.fit(element.offsetWidth, element.offsetHeight, shape || radius); |
| 705 | } |
| 706 | element.dataset.refraction = ""; |
| 707 | if (!refracting) { |
| 708 | refracting = true; |
| 709 | paint(); |
| 710 | } |
| 711 | } |
| 712 | element.dataset.ready = ""; |
| 713 | }; |
| 714 |
| 715 | const layout = (force = false) => { |
| 716 | window.clearTimeout(timer); |
| 717 | timer = 0; |
| 718 | const width = element.offsetWidth; |
| 719 | const height = element.offsetHeight; |
| 720 | if (!width || !height) return; |
| 721 | // Resize callbacks also arrive for unchanged boxes; skip the style read then. |
| 722 | const next = `${width}:${height}`; |
| 723 | if (!force && next === box) return; |
| 724 | box = next; |
| 725 | const exact = Math.min(parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0, width / 2, height / 2); |
| 726 | const target = Math.floor(exact); |
| 727 | if (target >= 1) shape = exact; |
| 728 | if (target >= 1 && target !== requested) { |
| 729 | const wait = lastBuild + REBUILD_INTERVAL - performance.now(); |
| 730 | if (wait <= 0) build(target); |
| 731 | // Keep the current slices while the radius animates, then catch up. |
| 732 | else timer = window.setTimeout(() => layout(true), wait); |
| 733 | } |
| 734 | if (!radius) return; |
| 735 | if (Math.abs(shape - masked) > 0.01) placeMasks(shape, false); |
| 736 | refraction?.fit(width, height, shape); |
| 737 | }; |
| 738 | const onTransitionEnd = (event: TransitionEvent) => { |
| 739 | if (event.target === surface && event.propertyName.includes("radius")) layout(true); |
| 740 | }; |
| 741 |
| 742 | let samples: 1 | 4 = 1; |
| 743 | const createRefraction = () => { |
| 744 | const created = createRefractionFilter(filterId, samples); |
| 745 | created.magnify(magnification); |
| 746 | return created; |
| 747 | }; |
| 748 | const setRefraction = (enabled: boolean, nextSamples: 1 | 4) => { |
| 749 | const resample = nextSamples !== samples; |
| 750 | samples = nextSamples; |
| 751 | if (enabled === Boolean(refraction) && !(enabled && resample)) return; |
| 752 | refraction?.remove(); |
| 753 | refraction = null; |
| 754 | if (enabled) { |
| 755 | // The body keeps naming the filter id, which the new filter takes over. |
| 756 | refraction = createRefraction(); |
| 757 | requested = 0; |
| 758 | layout(true); |
| 759 | return; |
| 760 | } |
| 761 | refracting = false; |
| 762 | delete element.dataset.refraction; |
| 763 | paint(); |
| 764 | }; |
| 765 |
| 766 | // Browser zoom changes the pixel ratio; rebuild the slices for it once the |
| 767 | // zoom settles. The current slices stay up meanwhile. |
| 768 | let resolution: MediaQueryList | null = null; |
| 769 | const watchResolution = () => { |
| 770 | resolution?.removeEventListener("change", onResolutionChange); |
| 771 | resolution = window.matchMedia(`(resolution: ${devicePixelRatio}dppx)`); |
| 772 | resolution.addEventListener("change", onResolutionChange); |
| 773 | }; |
| 774 | function onResolutionChange() { |
| 775 | watchResolution(); |
| 776 | window.clearTimeout(zoomTimer); |
| 777 | zoomTimer = window.setTimeout(() => { |
| 778 | const next = currentPixelRatio(); |
| 779 | if (next === pixelRatio) return; |
| 780 | pixelRatio = next; |
| 781 | requested = 0; |
| 782 | layout(true); |
| 783 | }, ZOOM_SETTLE); |
| 784 | } |
| 785 |
| 786 | // ── Fit ── |
| 787 | // A surface that scrolls itself: the glass spans its scroll height. Content growing |
| 788 | // inside the surface resizes its children, not the surface, so they are watched too. |
| 789 | let scrollFit: (() => void) | null = null; |
| 790 | const spanScroll = () => { |
| 791 | const fit = () => { |
| 792 | // The glass is part of the scrollable overflow, so measure without it or the |
| 793 | // surface could never shrink back. |
| 794 | element.style.height = "0px"; |
| 795 | element.style.height = `${surface.scrollHeight}px`; |
| 796 | }; |
| 797 | const resize = new ResizeObserver(fit); |
| 798 | const watch = () => { |
| 799 | resize.disconnect(); |
| 800 | resize.observe(surface); |
| 801 | for (const child of surface.children) { |
| 802 | if (child !== element) resize.observe(child); |
| 803 | } |
| 804 | }; |
| 805 | const children = new MutationObserver(() => { |
| 806 | watch(); |
| 807 | fit(); |
| 808 | }); |
| 809 | watch(); |
| 810 | fit(); |
| 811 | children.observe(surface, { childList: true }); |
| 812 | return () => { |
| 813 | resize.disconnect(); |
| 814 | children.disconnect(); |
| 815 | element.style.removeProperty("height"); |
| 816 | }; |
| 817 | }; |
| 818 | const setFit = (fit: LiquidGlassFit) => { |
| 819 | if ((fit === "scroll") === Boolean(scrollFit)) return; |
| 820 | if (scrollFit) { |
| 821 | scrollFit(); |
| 822 | scrollFit = null; |
| 823 | } else { |
| 824 | scrollFit = spanScroll(); |
| 825 | } |
| 826 | }; |
| 827 |
| 828 | // ── Light ── |
| 829 | // Interactive glass lights up from within: light added over the glass layers, |
| 830 | // under the content, since a brightness filter can't lift dark glass, which is |
| 831 | // near black. It follows the pointer through its own properties: set on the |
| 832 | // surface they would be inherited, restyling the surface's content on every move. |
| 833 | const lightUp = () => { |
| 834 | const node = document.createElement("span"); |
| 835 | node.className = "liquid-glass-light"; |
| 836 | element.append(node); |
| 837 | const place = (event: PointerEvent) => { |
| 838 | const frame = element.getBoundingClientRect(); |
| 839 | node.style.setProperty("--liquid-glass-x", `${event.clientX - frame.left}px`); |
| 840 | node.style.setProperty("--liquid-glass-y", `${event.clientY - frame.top}px`); |
| 841 | }; |
| 842 | const onEnter = (event: PointerEvent) => { |
| 843 | if (event.pointerType !== "touch") surface.toggleAttribute("data-liquid-glass-pointer", true); |
| 844 | }; |
| 845 | const onLeave = () => surface.removeAttribute("data-liquid-glass-pointer"); |
| 846 | // Touch only moves the light while pressed; a mouse moves it all the time. |
| 847 | const onMove = (event: PointerEvent) => { |
| 848 | if (event.pointerType !== "touch" || surface.hasAttribute("data-liquid-glass-pressed")) place(event); |
| 849 | }; |
| 850 | const onRelease = () => { |
| 851 | window.removeEventListener("pointerup", onRelease); |
| 852 | window.removeEventListener("pointercancel", onRelease); |
| 853 | surface.removeAttribute("data-liquid-glass-pressed"); |
| 854 | }; |
| 855 | const onDown = (event: PointerEvent) => { |
| 856 | if (event.button !== 0) return; |
| 857 | place(event); |
| 858 | surface.toggleAttribute("data-liquid-glass-pressed", true); |
| 859 | window.addEventListener("pointerup", onRelease); |
| 860 | window.addEventListener("pointercancel", onRelease); |
| 861 | }; |
| 862 | surface.addEventListener("pointerenter", onEnter); |
| 863 | surface.addEventListener("pointerleave", onLeave); |
| 864 | surface.addEventListener("pointermove", onMove); |
| 865 | surface.addEventListener("pointerdown", onDown); |
| 866 | return { |
| 867 | element: node, |
| 868 | remove() { |
| 869 | surface.removeEventListener("pointerenter", onEnter); |
| 870 | surface.removeEventListener("pointerleave", onLeave); |
| 871 | surface.removeEventListener("pointermove", onMove); |
| 872 | surface.removeEventListener("pointerdown", onDown); |
| 873 | onRelease(); |
| 874 | onLeave(); |
| 875 | node.remove(); |
| 876 | }, |
| 877 | }; |
| 878 | }; |
| 879 | const setInteractive = (enabled: boolean) => { |
| 880 | if (enabled === Boolean(light)) return; |
| 881 | if (enabled) light = lightUp(); |
| 882 | else { |
| 883 | light?.remove(); |
| 884 | light = null; |
| 885 | } |
| 886 | }; |
| 887 |
| 888 | // Repaint when the appearance this surface follows flips, and only then. |
| 889 | const onAppearanceSignal = () => { |
| 890 | if (resolveAppearance(options.appearance) !== appearance) paint(); |
| 891 | }; |
| 892 |
| 893 | const animate = (direction: "in" | "out", timing: GlassTiming = {}) => { |
| 894 | for (const animation of animations) animation.cancel(); |
| 895 | const effect = { |
| 896 | duration: timing.duration ?? 240, |
| 897 | easing: timing.easing ?? "cubic-bezier(0.22, 1, 0.36, 1)", |
| 898 | delay: timing.delay ?? 0, |
| 899 | fill: direction === "in" ? "backwards" : "forwards", |
| 900 | } satisfies KeyframeAnimationOptions; |
| 901 | const nodes = LAYERS.map(name => layers[name]); |
| 902 | animations = nodes.map(node => { |
| 903 | const full = node.style.getPropertyValue("--liquid-glass-full"); |
| 904 | const clear = node.style.getPropertyValue("--liquid-glass-clear"); |
| 905 | const wash = node.style.getPropertyValue("--liquid-glass-wash") || "transparent"; |
| 906 | const from = { backdropFilter: clear, webkitBackdropFilter: clear, backgroundColor: "transparent" }; |
| 907 | const to = { backdropFilter: full, webkitBackdropFilter: full, backgroundColor: wash }; |
| 908 | return node.animate(direction === "in" ? [from, to] : [to, from], effect); |
| 909 | }); |
| 910 | if (timing.scale !== undefined && timing.scale !== 1) { |
| 911 | const inset = `${((1 - timing.scale) * 50).toFixed(3)}%`; |
| 912 | const keyframes = [{ inset }, { inset: "0px" }]; |
| 913 | animations.push(element.animate(direction === "in" ? keyframes : keyframes.reverse(), effect)); |
| 914 | } |
| 915 | // A well on the glass can't fade with the surface's contents either, so it |
| 916 | // eases back to clear glass with the rest (styles.css). |
| 917 | surface.toggleAttribute("data-liquid-glass-leaving", direction === "out"); |
| 918 | return Promise.all(animations.map(animation => animation.finished.catch(() => undefined))).then(() => {}); |
| 919 | }; |
| 920 |
| 921 | paint(); |
| 922 | setFit(options.fit ?? "box"); |
| 923 | setInteractive(Boolean(options.interactive)); |
| 924 | samples = options.samples === 4 ? 4 : 1; |
| 925 | if (options.refraction !== false && supportsRefraction()) refraction = createRefraction(); |
| 926 | const observer = new ResizeObserver(() => layout()); |
| 927 | observer.observe(element); |
| 928 | surface.addEventListener("transitionend", onTransitionEnd); |
| 929 | const unwatchAppearance = watchAppearance(onAppearanceSignal); |
| 930 | watchResolution(); |
| 931 |
| 932 | return { |
| 933 | element, |
| 934 | get appearance() { |
| 935 | return appearance; |
| 936 | }, |
| 937 | update(next) { |
| 938 | const geometryChanged = JSON.stringify(next.geometry ?? {}) !== JSON.stringify(options.geometry ?? {}); |
| 939 | options = next; |
| 940 | paint(); |
| 941 | setFit(next.fit ?? "box"); |
| 942 | setInteractive(Boolean(next.interactive)); |
| 943 | setRefraction(next.refraction !== false && supportsRefraction(), next.samples === 4 ? 4 : 1); |
| 944 | if (geometryChanged) { |
| 945 | requested = 0; |
| 946 | layout(true); |
| 947 | } |
| 948 | }, |
| 949 | magnify(factor) { |
| 950 | magnification = factor; |
| 951 | refraction?.magnify(factor); |
| 952 | }, |
| 953 | materialize: timing => animate("in", timing), |
| 954 | dematerialize: timing => animate("out", timing), |
| 955 | destroy() { |
| 956 | disposed = true; |
| 957 | window.clearTimeout(timer); |
| 958 | window.clearTimeout(zoomTimer); |
| 959 | observer.disconnect(); |
| 960 | surface.removeEventListener("transitionend", onTransitionEnd); |
| 961 | unwatchAppearance(); |
| 962 | resolution?.removeEventListener("change", onResolutionChange); |
| 963 | for (const animation of animations) animation.cancel(); |
| 964 | scrollFit?.(); |
| 965 | scrollFit = null; |
| 966 | setInteractive(false); |
| 967 | refraction?.remove(); |
| 968 | hueLayer?.remove(); |
| 969 | delete element.dataset.ready; |
| 970 | delete element.dataset.refraction; |
| 971 | delete surface.dataset.liquidGlass; |
| 972 | surface.removeAttribute("data-liquid-glass-floating"); |
| 973 | surface.removeAttribute("data-liquid-glass-leaving"); |
| 974 | surface.style.removeProperty("--liquid-glass-solid"); |
| 975 | }, |
| 976 | }; |
| 977 | } |
| 978 |
| 979 | /** |
| 980 | * Turns any positioned, rounded element into a glass surface. The glass is inserted |
| 981 | * as its first child and removed again by `destroy()`. |
| 982 | * |
| 983 | * ```ts |
| 984 | * const glass = createLiquidGlass(document.querySelector(".toolbar")!); |
| 985 | * ``` |
| 986 | */ |
| 987 | export function createLiquidGlass(surface: HTMLElement, options: LiquidGlassOptions = {}): LiquidGlass { |
| 988 | const element = document.createElement("span"); |
| 989 | surface.prepend(element); |
| 990 | const glass = attachLiquidGlass(element, options); |
| 991 | return { |
| 992 | element, |
| 993 | get appearance() { |
| 994 | return glass.appearance; |
| 995 | }, |
| 996 | update: glass.update, |
| 997 | magnify: glass.magnify, |
| 998 | materialize: glass.materialize, |
| 999 | dematerialize: glass.dematerialize, |
| 1000 | destroy() { |
| 1001 | glass.destroy(); |
| 1002 | element.remove(); |
| 1003 | }, |
| 1004 | }; |
| 1005 | } |
| 1006 |