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 |
| SLICES, |
| 7 | sliceRects, |
| 8 | type GlassGeometry, |
| 9 | type GlassMask, |
| 10 | type GlassSlices, |
| 11 | type ShapedGlass, |
| 12 | } from "./lens.js"; |
| 13 | import { createGlassMorph, MORPH_RADII, type GlassPourOptions, type GlassPourSource } from "./morph.js"; |
| 14 | import { swellOnPress } from "./swell.js"; |
| 15 | import { |
| 16 | DEFAULT_HUE_STRENGTH, |
| 17 | DEFAULT_TINT, |
| 18 | hueMatrix, |
| 19 | resolveTone, |
| 20 | toneFilters, |
| 21 | toneWash, |
| 22 | type GlassAppearance, |
| 23 | type GlassFilter, |
| 24 | type GlassToneOverrides, |
| 25 | type GlassVariant, |
| 26 | } from "./tone.js"; |
| 27 |
| 28 | /* Liquid Glass after iOS 27, without a frosted fill. Every layer is the page behind |
| 29 | the glass, filtered: a body that diffuses it, the refraction bending it at the |
| 30 | rim, then darkened sides, an inner glow, a darkened outer edge and a rim hairline |
| 31 | gathered into glints, each masked to a point-wide band of the lens. Rim refraction |
| 32 | needs SVG filters in backdrop-filter, which only Chromium renders; WebKit and |
| 33 | Gecko would drop the whole backdrop, so they keep the glass without the bend. |
| 34 | Filters are set inline as literal values because Safari ignores custom |
| 35 | properties in -webkit-backdrop-filter. */ |
| 36 |
| 37 | const SVG_NS = "http://www.w3.org/2000/svg"; |
| 38 | // A resizing radius (a springing capsule, an opening menu) rebuilds at most this often. |
| 39 | const REBUILD_INTERVAL = 100; |
| 40 | // How long the pixel ratio must hold still before a zoom counts as finished. |
| 41 | const ZOOM_SETTLE = 150; |
| 42 | const LAYERS = ["body", "refract", "shade", "glow", "edge", "highlight"] as const; |
| 43 | type GlassLayer = (typeof LAYERS)[number]; |
| 44 |
| 45 | /** |
| 46 | * How the glass covers its surface: |
| 47 | * - `box`: fills the surface. For surfaces that don't scroll themselves. |
| 48 | * - `fixed`: fills the nearest containing block for fixed elements, such as a |
| 49 | * transformed popper wrapper, so the glass holds still while the surface |
| 50 | * scrolls its own content. |
| 51 | * - `scroll`: spans the surface's whole scroll height, so a surface that scrolls |
| 52 | * itself (a dialog, a sheet) stays glass all the way down. |
| 53 | */ |
| 54 | export type LiquidGlassFit = "box" | "fixed" | "scroll"; |
| 55 |
| 56 | export type LiquidGlassOptions = { |
| 57 | /** |
| 58 | * - `system` (default): follows prefers-color-scheme. |
| 59 | * - `class`: dark while `<html>` has the `dark` class, as with Tailwind's class |
| 60 | * strategy and next-themes. |
| 61 | */ |
| 62 | appearance?: GlassAppearance | "system" | "class" | undefined; |
| 63 | variant?: GlassVariant | undefined; |
| 64 | /** |
| 65 | * How clear or tinted regular glass is, as iOS 27's Liquid Glass slider sets it: |
| 66 | * 0 is More Clear, 0.5 (the default) a light frost and a faint wash, 1 More Tinted. |
| 67 | * Anything in between. The rim doesn't change with it. |
| 68 | */ |
| 69 | level?: number | undefined; |
| 70 | /** Per-appearance overrides of the variant's tone. */ |
| 71 | tone?: GlassToneOverrides | undefined; |
| 72 | geometry?: Partial<GlassGeometry> | undefined; |
| 73 | /** |
| 74 | * Glass of an outline no rounded rectangle makes: an SVG path of absolute M, L, H, |
| 75 | * V, C and Z commands in the surface's CSS pixels, or a function from its size to |
| 76 | * one. The glass is rendered whole for each size, which takes a few hundred |
| 77 | * milliseconds for a large surface, so keep shaped glass to surfaces that don't |
| 78 | * resize. Its rims stay within the surface's border-radius. |
| 79 | */ |
| 80 | shape?: string | ((width: number, height: number) => string) | undefined; |
| 81 | /** Colour washed over the body, by the tone's `tint` share. Any CSS colour, including var(). */ |
| 82 | tint?: string | undefined; |
| 83 | /** A colour whose hue the glass takes on, like tinted iOS glass. Any concrete CSS colour. */ |
| 84 | hue?: string | null | undefined; |
| 85 | /** How much of the hue the glass takes (0–1). Defaults to 1 on light glass and 0.5 on dark. */ |
| 86 | hueStrength?: number | undefined; |
| 87 | fit?: LiquidGlassFit | undefined; |
| 88 | /** Bend the page at the rim, where the browser supports it. On by default. */ |
| 89 | refraction?: boolean | undefined; |
| 90 | /** |
| 91 | * Refraction samples per pixel: 4 (the default) or 1. Chromium's |
| 92 | * feDisplacementMap takes the nearest backdrop pixel, so wherever the glass |
| 93 | * stretches or compresses what is beneath, edges stair-step and crawl as it |
| 94 | * moves. Four samples a quarter of a device pixel apart smooth them. They cost |
| 95 | * four displacement passes; 1 is cheaper for large glass. |
| 96 | */ |
| 97 | samples?: 1 | 4 | undefined; |
| 98 | /** |
| 99 | * For menus, popovers, dialogs and sheets. Floating glass diffuses the page more, |
| 100 | * as iOS 27's does, so text beneath its own text washes into soft colour instead |
| 101 | * of reading through, and the surface casts a drop shadow (--liquid-glass-shadow). |
| 102 | * `"dialog"` casts a dialog's deeper shadow (--liquid-glass-dialog-shadow). |
| 103 | */ |
| 104 | floating?: boolean | "dialog" | undefined; |
| 105 | /** |
| 106 | * For glass that holds controls, like SwiftUI's `.glassEffect(.regular.interactive())`: |
| 107 | * the pointer warms the glass beneath it, a press blooms out from there while the |
| 108 | * glass swells and springs back, and light glass floats on a faint shadow |
| 109 | * (--liquid-glass-control-shadow). |
| 110 | */ |
| 111 | interactive?: boolean | undefined; |
| 112 | /** |
| 113 | * Pressed, the surface swells about its centre and springs back with a slight |
| 114 | * overshoot on release; held and dragged, it leans after the pointer like taffy. |
| 115 | * On by default for interactive glass. For glass you press, like a search field. |
| 116 | */ |
| 117 | swell?: boolean | undefined; |
| 118 | }; |
| 119 |
| 120 | export type GlassTiming = { |
| 121 | /** Milliseconds. */ |
| 122 | duration?: number | undefined; |
| 123 | easing?: string | undefined; |
| 124 | delay?: number | undefined; |
| 125 | /** |
| 126 | * Grow the glass from (or shrink it to) this scale of the surface, for surfaces |
| 127 | * that zoom as they open and close. The glass changes size through its inset, |
| 128 | * since scaling it blanks it; scale the surface's contents, not the surface. |
| 129 | */ |
| 130 | scale?: number | undefined; |
| 131 | }; |
| 132 |
| 133 | export type LiquidGlass = { |
| 134 | /** The glass element: the first child of the surface. */ |
| 135 | readonly element: HTMLElement; |
| 136 | /** The resolved appearance. */ |
| 137 | readonly appearance: GlassAppearance; |
| 138 | update(options: LiquidGlassOptions): void; |
| 139 | /** |
| 140 | * Magnifies what is beneath the glass about its centre by `factor` (1 for none), |
| 141 | * as a raised lens does. Cheap enough to call every frame. Needs refraction. |
| 142 | */ |
| 143 | magnify(factor: number): void; |
| 144 | /** Grows the glass from clear to its tone, for surfaces that appear. */ |
| 145 | materialize(timing?: GlassTiming): Promise<void>; |
| 146 | /** Fades the glass back to clear and holds it there, for surfaces that leave. */ |
| 147 | dematerialize(timing?: GlassTiming): Promise<void>; |
| 148 | /** |
| 149 | * Pours the surface out of the button that opened it, or a point, as iOS 27's |
| 150 | * menus do: the glass starts as the button and inflates into the surface, and the |
| 151 | * contents ride inside it. Call it once the surface is shown and placed. Resolves |
| 152 | * true once it has landed, or false if drawBack() interrupted it. |
| 153 | */ |
| 154 | pourFrom(source: GlassPourSource, options?: GlassPourOptions): Promise<boolean>; |
| 155 | /** |
| 156 | * Draws the surface back into where it poured from: the contents go first and the |
| 157 | * glass shrinks into the button as a drop. Resolves true once the glass has gone, |
| 158 | * when you should hide the surface, or false if pourFrom() reopened it. |
| 159 | */ |
| 160 | drawBack(): Promise<boolean>; |
| 161 | destroy(): void; |
| 162 | }; |
| 163 |
| 164 | // ── Slices ────────────────────────────────────────────── |
| 165 |
| 166 | function imageUrl(pixels: Uint8ClampedArray, width: number, height: number) { |
| 167 | const canvas = document.createElement("canvas"); |
| 168 | canvas.width = width; |
| 169 | canvas.height = height; |
| 170 | canvas.getContext("2d")?.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0); |
| 171 | return canvas.toDataURL(); |
| 172 | } |
| 173 |
| 174 | function sliceUrls(pixels: Uint8ClampedArray, slices: GlassSlices) { |
| 175 | const source = document.createElement("canvas"); |
| 176 | source.width = slices.size; |
| 177 | source.height = slices.size; |
| 178 | source.getContext("2d")?.putImageData(new ImageData(new Uint8ClampedArray(pixels), slices.size, slices.size), 0, 0); |
| 179 | const piece = document.createElement("canvas"); |
| 180 | const context = piece.getContext("2d"); |
| 181 | return sliceRects(slices).map(([x, y, width, height]) => { |
| 182 | piece.width = width; |
| 183 | piece.height = height; |
| 184 | context?.drawImage(source, x, y, width, height, 0, 0, width, height); |
| 185 | return piece.toDataURL(); |
| 186 | }); |
| 187 | } |
| 188 |
| 189 | type BuiltGlass = { |
| 190 | slices: GlassSlices; |
| 191 | masks: Record<GlassMask, string[]>; |
| 192 | refraction: string[]; |
| 193 | /** Resolves once every slice is decoded; see decodeImages. */ |
| 194 | decoded: Promise<void>; |
| 195 | }; |
| 196 |
| 197 | // Chromium paints a masked layer without its mask until the mask image has decoded, |
| 198 | // and the rim layers brighten everything they cover, so a fresh mask flashes the |
| 199 | // whole surface light. Images are decoded before use and kept for as long as their |
| 200 | // build is cached, so masks and feImages that name these URLs find them ready. |
| 201 | const decodedImages = new WeakMap<object, HTMLImageElement[]>(); |
| 202 |
| 203 | function decodeImages(urls: string[]) { |
| 204 | const images = urls.map(url => { |
| 205 | const image = new Image(); |
| 206 | image.src = url; |
| 207 | return image; |
| 208 | }); |
| 209 | return { images, decoded: Promise.all(images.map(image => image.decode().catch(() => {}))).then(() => {}) }; |
| 210 | } |
| 211 |
| 212 | // A build is a pure function of radius, pixel ratio and geometry, and surfaces flip |
| 213 | // between a few sizes (full, compact, open), so repeat builds come from here instead |
| 214 | // of re-rendering the lens and re-encoding 40 slices mid-animation. There is room |
| 215 | // for a few kinds of glass on a page plus every radius a pouring menu and a tab |
| 216 | // lens pass through (prewarmLiquidGlass). |
| 217 | const builds = new Map<string, BuiltGlass>(); |
| 218 | const BUILD_CACHE_SIZE = 96; |
| 219 |
| 220 | const buildKey = (radius: number, pixelRatio: number, geometry: Partial<GlassGeometry>) => |
| 221 | `${radius}:${pixelRatio}:${JSON.stringify(geometry)}`; |
| 222 |
| 223 | /** |
| 224 | * The pixel ratio slices are built at: the screen's, rounded up to whole pixels, up |
| 225 | * to 3. Zoomed to 110% a 2x screen is 2.2x, and slices built at 2 were stretched. |
| 226 | */ |
| 227 | function slicePixelRatio() { |
| 228 | return Math.min(Math.ceil(devicePixelRatio), 3); |
| 229 | } |
| 230 |
| 231 | function buildGlass(radius: number, pixelRatio: number, geometry: Partial<GlassGeometry>) { |
| 232 | const key = buildKey(radius, pixelRatio, geometry); |
| 233 | const cached = builds.get(key); |
| 234 | if (cached) { |
| 235 | // Most recently used last, so eviction takes the build that has gone longest unused. |
| 236 | builds.delete(key); |
| 237 | builds.set(key, cached); |
| 238 | return cached; |
| 239 | } |
| 240 | const slices = createGlassSlices(radius, pixelRatio, geometry); |
| 241 | const urls = { |
| 242 | slices, |
| 243 | masks: { |
| 244 | highlight: sliceUrls(slices.masks.highlight, slices), |
| 245 | shade: sliceUrls(slices.masks.shade, slices), |
| 246 | glow: sliceUrls(slices.masks.glow, slices), |
| 247 | // Glass without a darkened edge has no edge layer to mask. |
| 248 | edge: (geometry.edge ?? DEFAULT_GEOMETRY.edge) > 0 ? sliceUrls(slices.masks.edge, slices) : [], |
| 249 | refract: sliceUrls(slices.masks.refract, slices), |
| 250 | }, |
| 251 | refraction: sliceUrls(slices.refraction, slices), |
| 252 | }; |
| 253 | const { images, decoded } = decodeImages([...MASKS.flatMap(mask => urls.masks[mask]), ...urls.refraction]); |
| 254 | const built: BuiltGlass = { ...urls, decoded }; |
| 255 | decodedImages.set(built, images); |
| 256 | builds.set(key, built); |
| 257 | if (builds.size > BUILD_CACHE_SIZE) builds.delete(builds.keys().next().value ?? ""); |
| 258 | return built; |
| 259 | } |
| 260 |
| 261 | const idle = (callback: () => void, timeout: number) => { |
| 262 | // Safari has no requestIdleCallback. |
| 263 | if (typeof window.requestIdleCallback === "function") window.requestIdleCallback(callback, { timeout }); |
| 264 | else window.setTimeout(callback, 16); |
| 265 | }; |
| 266 |
| 267 | /** |
| 268 | * Builds the glass for these corner radii ahead of time, while the page is idle, |
| 269 | * so glass whose corners animate through them swaps to each at once instead of |
| 270 | * stretching the last build over it. By default, every radius a menu passes |
| 271 | * through as it pours out of its button (pourFrom), for glass of `geometry`. |
| 272 | * Floating glass does this by itself the first time it appears; call it at startup |
| 273 | * if your menus mount only as they open, so the first one pours out sharp. |
| 274 | */ |
| 275 | export function prewarmLiquidGlass(radii: readonly number[] = MORPH_RADII, geometry: Partial<GlassGeometry> = {}) { |
| 276 | if (typeof window === "undefined") return; |
| 277 | const pixelRatio = slicePixelRatio(); |
| 278 | const queue = radii |
| 279 | .map(radius => Math.floor(radius)) |
| 280 | .filter(radius => radius >= 1 && !builds.has(buildKey(radius, pixelRatio, geometry))); |
| 281 | // One build per idle moment: each takes a few milliseconds, and twenty at once |
| 282 | // held the page for over a tenth of a second. |
| 283 | const next = () => { |
| 284 | const radius = queue.shift(); |
| 285 | if (radius === undefined) return; |
| 286 | buildGlass(radius, pixelRatio, geometry); |
| 287 | if (queue.length) idle(next, 2000); |
| 288 | }; |
| 289 | idle(next, 4000); |
| 290 | } |
| 291 |
| 292 | // Geometries whose morph radii are built or on their way. |
| 293 | const prewarmed = new Set<string>(); |
| 294 |
| 295 | function prewarmMorph(geometry: Partial<GlassGeometry>) { |
| 296 | const key = JSON.stringify(geometry); |
| 297 | if (prewarmed.has(key)) return; |
| 298 | prewarmed.add(key); |
| 299 | prewarmLiquidGlass(MORPH_RADII, geometry); |
| 300 | } |
| 301 |
| 302 | type BuiltShape = { |
| 303 | glass: ShapedGlass; |
| 304 | masks: Record<GlassMask, string>; |
| 305 | refraction: string; |
| 306 | decoded: Promise<void>; |
| 307 | }; |
| 308 |
| 309 | const shapedBuilds = new Map<string, BuiltShape>(); |
| 310 | // Each size of each shape builds its own; keep the few in use. |
| 311 | const SHAPED_CACHE_SIZE = 12; |
| 312 |
| 313 | function buildShape(path: string, width: number, height: number, radius: number, pixelRatio: number, geometry: Partial<GlassGeometry>) { |
| 314 | const key = `${path}:${width}x${height}:${buildKey(radius, pixelRatio, geometry)}`; |
| 315 | const cached = shapedBuilds.get(key); |
| 316 | if (cached) return cached; |
| 317 | const glass = createShapedGlass(path, width, height, radius, pixelRatio, geometry); |
| 318 | const url = (pixels: Uint8ClampedArray) => imageUrl(pixels, glass.width, glass.height); |
| 319 | const masks = { |
| 320 | highlight: url(glass.masks.highlight), |
| 321 | shade: url(glass.masks.shade), |
| 322 | glow: url(glass.masks.glow), |
| 323 | edge: (geometry.edge ?? DEFAULT_GEOMETRY.edge) > 0 ? url(glass.masks.edge) : "", |
| 324 | refract: url(glass.masks.refract), |
| 325 | }; |
| 326 | const refraction = url(glass.refraction); |
| 327 | const { images, decoded } = decodeImages([...Object.values(masks).filter(Boolean), refraction]); |
| 328 | const built: BuiltShape = { glass, masks, refraction, decoded }; |
| 329 | decodedImages.set(built, images); |
| 330 | shapedBuilds.set(key, built); |
| 331 | if (shapedBuilds.size > SHAPED_CACHE_SIZE) shapedBuilds.delete(shapedBuilds.keys().next().value ?? ""); |
| 332 | return built; |
| 333 | } |
| 334 |
| 335 | /** CSS mask layers that assemble the slices over any box. */ |
| 336 | function maskStyles(urls: string[], radius: number) { |
| 337 | const r = `${radius}px`; |
| 338 | const span = `calc(100% - ${radius * 2}px)`; |
| 339 | return { |
| 340 | image: urls.map(url => `url("${url}")`).join(", "), |
| 341 | position: `0 0, 100% 0, 0 100%, 100% 100%, ${r} 0, ${r} 100%, 0 ${r}, 100% ${r}`, |
| 342 | size: `${r} ${r}, ${r} ${r}, ${r} ${r}, ${r} ${r}, ${span} ${r}, ${span} ${r}, ${r} ${span}, ${r} ${span}`, |
| 343 | }; |
| 344 | } |
| 345 |
| 346 | function setMask(node: HTMLElement, image: string, position: string, size: string) { |
| 347 | for (const prefix of ["-webkit-", ""]) { |
| 348 | node.style.setProperty(`${prefix}mask-image`, image); |
| 349 | node.style.setProperty(`${prefix}mask-position`, position); |
| 350 | node.style.setProperty(`${prefix}mask-size`, size); |
| 351 | node.style.setProperty(`${prefix}mask-repeat`, "no-repeat"); |
| 352 | } |
| 353 | } |
| 354 |
| 355 | function clearMask(node: HTMLElement) { |
| 356 | for (const prefix of ["-webkit-", ""]) { |
| 357 | for (const property of ["mask-image", "mask-position", "mask-size", "mask-repeat"]) node.style.removeProperty(`${prefix}${property}`); |
| 358 | } |
| 359 | } |
| 360 |
| 361 | // ── Filters ───────────────────────────────────────────── |
| 362 |
| 363 | /** True where SVG filters work inside backdrop-filter. */ |
| 364 | export function supportsRefraction() { |
| 365 | if (typeof navigator === "undefined") return false; |
| 366 | const brands = (navigator as Navigator & { userAgentData?: { brands: { brand: string }[] } }) |
| 367 | .userAgentData?.brands; |
| 368 | if (brands) return brands.some(({ brand }) => brand === "Chromium"); |
| 369 | // userAgentData only exists on secure origins; plain-http pages fall back to the |
| 370 | // user agent string. Chrome on iOS is WebKit and says "CriOS", not "Chrome/". |
| 371 | return /Chrome\//.test(navigator.userAgent); |
| 372 | } |
| 373 |
| 374 | let instances = 0; |
| 375 | function uniqueId(prefix: string) { |
| 376 | instances += 1; |
| 377 | return `${prefix}-${instances.toString(36)}${Math.random().toString(36).slice(2, 7)}`; |
| 378 | } |
| 379 |
| 380 | function hiddenSvg() { |
| 381 | const svg = document.createElementNS(SVG_NS, "svg"); |
| 382 | svg.setAttribute("aria-hidden", "true"); |
| 383 | svg.setAttribute("width", "0"); |
| 384 | svg.setAttribute("height", "0"); |
| 385 | // display: none would disable the filter; keep it rendered but out of flow. |
| 386 | svg.style.position = "absolute"; |
| 387 | svg.style.pointerEvents = "none"; |
| 388 | return svg; |
| 389 | } |
| 390 |
| 391 | // Device pixels from the pixel centre to each of the four refraction samples. |
| 392 | const SAMPLE_OFFSET = 0.25; |
| 393 |
| 394 | let magnificationField: { url: string; decoded: Promise<void>; image: HTMLImageElement } | null = null; |
| 395 |
| 396 | /** |
| 397 | * A map whose red and green run from 0 to 1 across and down it. Stretched over |
| 398 | * the glass, it is a displacement that grows linearly from the centre: a lens. |
| 399 | * Decoded before use, like the slices, and kept for the page's lifetime. |
| 400 | */ |
| 401 | function magnificationRamp() { |
| 402 | if (magnificationField) return magnificationField; |
| 403 | const size = 256; |
| 404 | const pixels = new Uint8ClampedArray(size * size * 4); |
| 405 | for (let row = 0; row < size; row++) { |
| 406 | for (let column = 0; column < size; column++) { |
| 407 | const index = (row * size + column) * 4; |
| 408 | pixels[index] = column; |
| 409 | pixels[index + 1] = row; |
| 410 | pixels[index + 2] = 128; |
| 411 | pixels[index + 3] = 255; |
| 412 | } |
| 413 | } |
| 414 | const url = imageUrl(pixels, size, size); |
| 415 | const image = new Image(); |
| 416 | image.src = url; |
| 417 | magnificationField = { url, image, decoded: image.decode().catch(() => {}) }; |
| 418 | return magnificationField; |
| 419 | } |
| 420 |
| 421 | /** |
| 422 | * An SVG filter that assembles refraction slices (or one whole map, for shaped |
| 423 | * glass) into a displacement map, adds a magnifying field over the whole box while |
| 424 | * magnifying, and displaces the backdrop by it with one sample per pixel or four. |
| 425 | */ |
| 426 | function createRefractionFilter(id: string, samples: 1 | 4) { |
| 427 | const svg = hiddenSvg(); |
| 428 | const filter = document.createElementNS(SVG_NS, "filter"); |
| 429 | filter.id = id; |
| 430 | filter.setAttribute("x", "0"); |
| 431 | filter.setAttribute("y", "0"); |
| 432 | filter.setAttribute("filterUnits", "userSpaceOnUse"); |
| 433 | filter.setAttribute("primitiveUnits", "userSpaceOnUse"); |
| 434 | filter.setAttribute("color-interpolation-filters", "sRGB"); |
| 435 | // Uncovered map pixels stay neutral rather than displacing by half the scale. |
| 436 | const neutral = document.createElementNS(SVG_NS, "feFlood"); |
| 437 | neutral.setAttribute("flood-color", "rgb(128, 128, 128)"); |
| 438 | neutral.setAttribute("result", "neutral"); |
| 439 | const merge = document.createElementNS(SVG_NS, "feMerge"); |
| 440 | merge.setAttribute("result", "map"); |
| 441 | const neutralNode = document.createElementNS(SVG_NS, "feMergeNode"); |
| 442 | neutralNode.setAttribute("in", "neutral"); |
| 443 | const images = SLICES.map(name => { |
| 444 | const image = document.createElementNS(SVG_NS, "feImage"); |
| 445 | image.setAttribute("preserveAspectRatio", "none"); |
| 446 | image.setAttribute("result", `slice-${name}`); |
| 447 | return image; |
| 448 | }); |
| 449 | const mergeNodes = SLICES.map(name => { |
| 450 | const node = document.createElementNS(SVG_NS, "feMergeNode"); |
| 451 | node.setAttribute("in", `slice-${name}`); |
| 452 | return node; |
| 453 | }); |
| 454 | const primitive = (name: string, attributes: Record<string, string>) => { |
| 455 | const node = document.createElementNS(SVG_NS, name); |
| 456 | for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); |
| 457 | return node; |
| 458 | }; |
| 459 | // Magnifying, the rim's map ("rim") and the field scaled to the magnification |
| 460 | // ("field") add up to the map. Both share the displacement's one scale. |
| 461 | const field = primitive("feImage", { x: "0", y: "0", preserveAspectRatio: "none", result: "field-ramp" }); |
| 462 | const fieldScale = primitive("feColorMatrix", { in: "field-ramp", type: "matrix", result: "field" }); |
| 463 | const combine = primitive("feComposite", { in: "rim", in2: "field", operator: "arithmetic", k1: "0", k3: "1", result: "map" }); |
| 464 | // Magnified, what is beneath is resampled and comes out soft, worst on 1x |
| 465 | // screens; a light sharpen, growing with the magnification, restores it. |
| 466 | const sharpen = primitive("feConvolveMatrix", { order: "3", divisor: "1", preserveAlpha: "true", edgeMode: "duplicate" }); |
| 467 | // Four samples read the map shifted a quarter of a device pixel each way and |
| 468 | // average what they displace. |
| 469 | const four = samples === 4; |
| 470 | const shifts = four |
| 471 | ? [0, 1, 2, 3].map(index => primitive("feColorMatrix", { in: "map", type: "matrix", result: `map-${index}` })) |
| 472 | : []; |
| 473 | const displacements = (four ? [0, 1, 2, 3] : [0]).map(index => |
| 474 | primitive("feDisplacementMap", { |
| 475 | in: "SourceGraphic", |
| 476 | in2: four ? `map-${index}` : "map", |
| 477 | xChannelSelector: "R", |
| 478 | yChannelSelector: "G", |
| 479 | result: `sample-${index}`, |
| 480 | }), |
| 481 | ); |
| 482 | const average = (a: string, b: string, result?: string) => |
| 483 | primitive("feComposite", { in: a, in2: b, operator: "arithmetic", k2: "0.5", k3: "0.5", ...(result ? { result } : {}) }); |
| 484 | const averages = four |
| 485 | ? [average("sample-0", "sample-1", "pair-0"), average("sample-2", "sample-3", "pair-1"), average("pair-0", "pair-1")] |
| 486 | : []; |
| 487 | svg.append(filter); |
| 488 | document.body.append(svg); |
| 489 |
| 490 | let rimScale = 0; |
| 491 | let magnification = 1; |
| 492 | let fieldRequested = false; |
| 493 | let fieldReady = false; |
| 494 | let width = 0; |
| 495 | let height = 0; |
| 496 | let removed = false; |
| 497 | const magnifying = () => fieldReady && magnification > 1.0005; |
| 498 |
| 499 | // The filter runs every frame the page moves beneath the glass, so it only carries |
| 500 | // the primitives the current box needs. A capsule's corners and top and bottom edges |
| 501 | // tile it completely, leaving the side slices empty and the neutral fill unseen. |
| 502 | let structure = ""; |
| 503 | let used: boolean[] = []; |
| 504 | let covered = false; |
| 505 | const restructure = () => { |
| 506 | const lens = magnifying(); |
| 507 | // Where slices meet off the pixel grid, the map is partly transparent. |
| 508 | // Displacement reads it unpremultiplied and doesn't mind, but adding the |
| 509 | // field would shift that row, so a magnifying lens keeps the neutral fill. |
| 510 | const flood = !covered || lens; |
| 511 | const key = `${used.join()}:${flood}:${lens}`; |
| 512 | if (key === structure) return; |
| 513 | structure = key; |
| 514 | const keep = <Item,>(items: Item[]) => items.filter((_, index) => used[index]); |
| 515 | merge.setAttribute("result", lens ? "rim" : "map"); |
| 516 | merge.replaceChildren(...(flood ? [neutralNode] : []), ...keep(mergeNodes)); |
| 517 | filter.replaceChildren( |
| 518 | ...(flood ? [neutral] : []), |
| 519 | ...keep(images), |
| 520 | merge, |
| 521 | ...(lens ? [field, fieldScale, combine] : []), |
| 522 | ...displacements.flatMap((displacement, index) => [...(shifts[index] ? [shifts[index]] : []), displacement]), |
| 523 | ...averages, |
| 524 | ...(lens ? [sharpen] : []), |
| 525 | ); |
| 526 | }; |
| 527 |
| 528 | // Writes the values that follow the box, the magnification and the pixel ratio. |
| 529 | const update = () => { |
| 530 | // Magnifying by m, a point d from the centre shows the backdrop d / m from it. |
| 531 | const pull = magnifying() ? 1 - 1 / magnification : 0; |
| 532 | const scale = rimScale + pull * Math.max(width, height); |
| 533 | for (const displacement of displacements) displacement.setAttribute("scale", scale.toFixed(3)); |
| 534 | if (magnifying() && scale > 0) { |
| 535 | const share = rimScale / scale; |
| 536 | combine.setAttribute("k2", share.toFixed(5)); |
| 537 | combine.setAttribute("k4", (-share / 2).toFixed(5)); |
| 538 | const x = (-pull * width) / scale; |
| 539 | const y = (-pull * height) / scale; |
| 540 | 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`); |
| 541 | // Tuned by eye at 16%: 0.2 on 1x screens, less at 2x, where it is softened less. |
| 542 | const amount = (magnification - 1) * ((window.devicePixelRatio || 1) >= 1.5 ? 0.85 : 1.25); |
| 543 | sharpen.setAttribute("kernelMatrix", `0 ${-amount} 0 ${-amount} ${1 + 4 * amount} ${-amount} 0 ${-amount} 0`); |
| 544 | } |
| 545 | if (shifts.length && scale > 0) { |
| 546 | const offset = SAMPLE_OFFSET / (window.devicePixelRatio || 1) / scale; |
| 547 | const signs = [[1, 1], [-1, 1], [1, -1], [-1, -1]] as const; |
| 548 | shifts.forEach((shift, index) => { |
| 549 | const [sx, sy] = signs[index] ?? [0, 0]; |
| 550 | 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`); |
| 551 | }); |
| 552 | } |
| 553 | }; |
| 554 | const refresh = () => { |
| 555 | restructure(); |
| 556 | update(); |
| 557 | }; |
| 558 |
| 559 | return { |
| 560 | /** The slices' images, in SLICES order, or one whole map for shaped glass. */ |
| 561 | setMaps(urls: string[], scale: number) { |
| 562 | images.forEach((image, index) => image.setAttribute("href", urls[index] ?? "")); |
| 563 | rimScale = scale; |
| 564 | update(); |
| 565 | }, |
| 566 | /** |
| 567 | * Places the slices over the current box; cheap enough for every resize. A |
| 568 | * radius of 0 lays the first map over the whole box, as shaped glass has. |
| 569 | */ |
| 570 | fit(boxWidth: number, boxHeight: number, radius: number) { |
| 571 | width = boxWidth; |
| 572 | height = boxHeight; |
| 573 | const span = Math.max(0, width - radius * 2); |
| 574 | const tall = Math.max(0, height - radius * 2); |
| 575 | const rects = radius > 0 |
| 576 | ? [ |
| 577 | [0, 0, radius, radius], [width - radius, 0, radius, radius], |
| 578 | [0, height - radius, radius, radius], [width - radius, height - radius, radius, radius], |
| 579 | [radius, 0, span, radius], [radius, height - radius, span, radius], |
| 580 | [0, radius, radius, tall], [width - radius, radius, radius, tall], |
| 581 | ] |
| 582 | : [[0, 0, width, height]]; |
| 583 | used = SLICES.map((_, index) => { |
| 584 | const [, , w = 0, h = 0] = rects[index] ?? []; |
| 585 | return w > 0 && h > 0; |
| 586 | }); |
| 587 | covered = radius <= 0 || tall === 0; |
| 588 | filter.setAttribute("width", String(width)); |
| 589 | filter.setAttribute("height", String(height)); |
| 590 | images.forEach((image, index) => { |
| 591 | const [x = 0, y = 0, w = 0, h = 0] = rects[index] ?? []; |
| 592 | image.setAttribute("x", String(x)); |
| 593 | image.setAttribute("y", String(y)); |
| 594 | image.setAttribute("width", String(w)); |
| 595 | image.setAttribute("height", String(h)); |
| 596 | }); |
| 597 | field.setAttribute("width", String(width)); |
| 598 | field.setAttribute("height", String(height)); |
| 599 | refresh(); |
| 600 | }, |
| 601 | /** Magnifies what is beneath the glass by `factor` (1 for none). */ |
| 602 | magnify(factor: number) { |
| 603 | if (factor > 1 && !fieldRequested) { |
| 604 | // The field is decoded before its first use, like the slices. |
| 605 | fieldRequested = true; |
| 606 | const ramp = magnificationRamp(); |
| 607 | field.setAttribute("href", ramp.url); |
| 608 | void ramp.decoded.then(() => { |
| 609 | if (removed) return; |
| 610 | fieldReady = true; |
| 611 | refresh(); |
| 612 | }); |
| 613 | } |
| 614 | if (Math.abs(factor - magnification) < 0.0005) return; |
| 615 | magnification = factor; |
| 616 | refresh(); |
| 617 | }, |
| 618 | remove() { |
| 619 | removed = true; |
| 620 | svg.remove(); |
| 621 | }, |
| 622 | }; |
| 623 | } |
| 624 |
| 625 | // Hue filters are shared: every surface with the same hue and strength names one. |
| 626 | let hueDefs: SVGSVGElement | null = null; |
| 627 | const hueFilters = new Map<string, string>(); |
| 628 |
| 629 | function hueFilterId(matrix: string) { |
| 630 | const existing = hueFilters.get(matrix); |
| 631 | if (existing && document.getElementById(existing)) return existing; |
| 632 | if (!hueDefs?.isConnected) { |
| 633 | hueDefs = hiddenSvg(); |
| 634 | document.body.append(hueDefs); |
| 635 | } |
| 636 | const id = uniqueId("liquid-glass-hue"); |
| 637 | const filter = document.createElementNS(SVG_NS, "filter"); |
| 638 | filter.id = id; |
| 639 | filter.setAttribute("color-interpolation-filters", "sRGB"); |
| 640 | const colour = document.createElementNS(SVG_NS, "feColorMatrix"); |
| 641 | colour.setAttribute("type", "matrix"); |
| 642 | colour.setAttribute("values", matrix); |
| 643 | filter.append(colour); |
| 644 | hueDefs.append(filter); |
| 645 | hueFilters.set(matrix, id); |
| 646 | return id; |
| 647 | } |
| 648 |
| 649 | let colourProbe: CanvasRenderingContext2D | null = null; |
| 650 |
| 651 | /** Any CSS colour as sRGB channels, by painting one pixel. */ |
| 652 | function parseColour(colour: string): [number, number, number] | null { |
| 653 | colourProbe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true }); |
| 654 | if (!colourProbe) return null; |
| 655 | colourProbe.canvas.width = 1; |
| 656 | colourProbe.canvas.height = 1; |
| 657 | colourProbe.clearRect(0, 0, 1, 1); |
| 658 | colourProbe.fillStyle = "#000"; |
| 659 | colourProbe.fillStyle = colour; |
| 660 | colourProbe.fillRect(0, 0, 1, 1); |
| 661 | const [r = 0, g = 0, b = 0] = colourProbe.getImageData(0, 0, 1, 1).data; |
| 662 | return [r, g, b]; |
| 663 | } |
| 664 |
| 665 | // ── Appearance ────────────────────────────────────────── |
| 666 |
| 667 | const DARK_QUERY = "(prefers-color-scheme: dark)"; |
| 668 |
| 669 | export function resolveAppearance(appearance: LiquidGlassOptions["appearance"]): GlassAppearance { |
| 670 | if (appearance === "dark" || appearance === "light") return appearance; |
| 671 | if (appearance === "class") return document.documentElement.classList.contains("dark") ? "dark" : "light"; |
| 672 | return matchMedia(DARK_QUERY).matches ? "dark" : "light"; |
| 673 | } |
| 674 |
| 675 | // One observer serves every surface that follows the root's class. |
| 676 | const classListeners = new Set<() => void>(); |
| 677 | let classObserver: MutationObserver | null = null; |
| 678 |
| 679 | function watchRootClass(listener: () => void) { |
| 680 | classListeners.add(listener); |
| 681 | if (!classObserver) { |
| 682 | classObserver = new MutationObserver(() => { |
| 683 | for (const notify of classListeners) notify(); |
| 684 | }); |
| 685 | classObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); |
| 686 | } |
| 687 | return () => { |
| 688 | classListeners.delete(listener); |
| 689 | if (classListeners.size === 0) { |
| 690 | classObserver?.disconnect(); |
| 691 | classObserver = null; |
| 692 | } |
| 693 | }; |
| 694 | } |
| 695 |
| 696 | /** Calls back whenever a `system` or `class` appearance may have flipped. */ |
| 697 | export function watchAppearance(listener: () => void) { |
| 698 | const system = matchMedia(DARK_QUERY); |
| 699 | system.addEventListener("change", listener); |
| 700 | const unwatchRootClass = watchRootClass(listener); |
| 701 | return () => { |
| 702 | system.removeEventListener("change", listener); |
| 703 | unwatchRootClass(); |
| 704 | }; |
| 705 | } |
| 706 |
| 707 | // ── Glass ─────────────────────────────────────────────── |
| 708 |
| 709 | function setFilter(node: HTMLElement, filter: GlassFilter) { |
| 710 | node.style.setProperty("-webkit-backdrop-filter", filter.full); |
| 711 | node.style.setProperty("backdrop-filter", filter.full); |
| 712 | // The materialize keyframes in styles.css grow from one to the other. |
| 713 | node.style.setProperty("--liquid-glass-full", filter.full); |
| 714 | node.style.setProperty("--liquid-glass-clear", filter.clear); |
| 715 | } |
| 716 |
| 717 | /** The outline of shaped glass as a mask image, for the layers that fill it. */ |
| 718 | function outlineMask(path: string, width: number, height: number) { |
| 719 | const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><path d="${path}"/></svg>`; |
| 720 | return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; |
| 721 | } |
| 722 |
| 723 | /** |
| 724 | * Builds glass inside an existing element, which must be the first child of a |
| 725 | * positioned, rounded surface. The glass takes the surface's corner radius and sits |
| 726 | * behind its content. Layers the element already holds (`[data-layer]`) are kept, |
| 727 | * so server-rendered markup hydrates without a flash; missing ones are created. |
| 728 | */ |
| 729 | export function attachLiquidGlass(element: HTMLElement, initial: LiquidGlassOptions = {}): LiquidGlass { |
| 730 | const surface = element.parentElement; |
| 731 | if (!surface) throw new Error("Liquid glass needs a surface: attach it to a child of the rounded element."); |
| 732 | let options = initial; |
| 733 | element.classList.add("liquid-glass"); |
| 734 | element.setAttribute("aria-hidden", "true"); |
| 735 |
| 736 | const layers = {} as Record<GlassLayer, HTMLElement>; |
| 737 | let previous: HTMLElement | null = null; |
| 738 | for (const name of LAYERS) { |
| 739 | let node = element.querySelector<HTMLElement>(`:scope > [data-layer="${name}"]`); |
| 740 | if (!node) { |
| 741 | node = document.createElement("span"); |
| 742 | node.className = "liquid-glass-layer"; |
| 743 | node.dataset.layer = name; |
| 744 | if (previous) previous.after(node); |
| 745 | else element.prepend(node); |
| 746 | } |
| 747 | layers[name] = node; |
| 748 | previous = node; |
| 749 | } |
| 750 | let hueLayer: HTMLElement | null = null; |
| 751 | // Interactive glass's pointer light; see lightUp. |
| 752 | let light: { element: HTMLElement; remove: () => void } | null = null; |
| 753 |
| 754 | const filterId = uniqueId("liquid-glass-refraction"); |
| 755 | let refraction: ReturnType<typeof createRefractionFilter> | null = null; |
| 756 | // True once refraction maps are on screen; the refract layer names the filter from then on. |
| 757 | let refracting = false; |
| 758 | let appearance: GlassAppearance = "dark"; |
| 759 | let animations: Animation[] = []; |
| 760 | let animating = 0; |
| 761 | // The outline mask of shaped glass, for the layers that fill it. |
| 762 | let outline = ""; |
| 763 |
| 764 | const paint = () => { |
| 765 | const variant = options.variant ?? "regular"; |
| 766 | const floating = Boolean(options.floating); |
| 767 | appearance = resolveAppearance(options.appearance); |
| 768 | const tone = resolveTone(variant, appearance, options.tone, options.level); |
| 769 | const filters = toneFilters(tone, floating); |
| 770 | const tint = options.tint ?? DEFAULT_TINT[appearance]; |
| 771 | const wash = toneWash(tone, tint, floating); |
| 772 |
| 773 | setFilter(layers.body, filters.body); |
| 774 | layers.body.style.backgroundColor = wash; |
| 775 | layers.body.style.setProperty("--liquid-glass-wash", wash); |
| 776 | // The refraction bends the body's glass, as seen through its own backdrop. |
| 777 | layers.refract.style.setProperty("backdrop-filter", refracting ? `url(#${filterId})` : "none"); |
| 778 | setFilter(layers.shade, filters.shade); |
| 779 | setFilter(layers.glow, filters.glow); |
| 780 | setFilter(layers.edge, filters.edge); |
| 781 | setFilter(layers.highlight, filters.highlight); |
| 782 |
| 783 | element.dataset.appearance = appearance; |
| 784 | element.dataset.variant = variant; |
| 785 | element.dataset.fit = options.fit ?? "box"; |
| 786 | surface.dataset.liquidGlass = appearance; |
| 787 | if (options.floating) surface.setAttribute("data-liquid-glass-floating", options.floating === "dialog" ? "dialog" : ""); |
| 788 | else surface.removeAttribute("data-liquid-glass-floating"); |
| 789 | surface.toggleAttribute("data-liquid-glass-interactive", Boolean(options.interactive)); |
| 790 | // The solid fill that replaces the glass under prefers-reduced-transparency. |
| 791 | surface.style.setProperty("--liquid-glass-solid", tint); |
| 792 | // For rules that follow the level, like vertical tabs' wells. |
| 793 | surface.style.setProperty("--liquid-glass-level", String(Math.min(Math.max(options.level ?? 0.5, 0), 1))); |
| 794 |
| 795 | const rgb = options.hue ? parseColour(options.hue) : null; |
| 796 | const matrix = rgb ? hueMatrix(rgb, options.hueStrength ?? DEFAULT_HUE_STRENGTH[appearance]) : null; |
| 797 | if (matrix) { |
| 798 | if (!hueLayer) { |
| 799 | hueLayer = document.createElement("span"); |
| 800 | hueLayer.className = "liquid-glass-hue"; |
| 801 | // Under the pointer light, which is added over everything else. |
| 802 | if (light) light.element.before(hueLayer); |
| 803 | else element.append(hueLayer); |
| 804 | if (outline) setMask(hueLayer, outline, "0 0", "100% 100%"); |
| 805 | } |
| 806 | const hue = `url(#${hueFilterId(matrix)})`; |
| 807 | hueLayer.style.setProperty("-webkit-backdrop-filter", hue); |
| 808 | hueLayer.style.setProperty("backdrop-filter", hue); |
| 809 | } else { |
| 810 | hueLayer?.remove(); |
| 811 | hueLayer = null; |
| 812 | } |
| 813 | }; |
| 814 |
| 815 | // ── Lens ── |
| 816 | let pixelRatio = slicePixelRatio(); |
| 817 | // `requested` is the radius being built; `radius` is the one on screen, which |
| 818 | // trails it until the new slices have decoded. `shape` is the box's own radius: |
| 819 | // the slices stretch to it, so while the radius animates between rebuilds the rim |
| 820 | // keeps the box's shape, a little wider or narrower, instead of cutting across |
| 821 | // its corners. |
| 822 | let requested = 0; |
| 823 | let radius = 0; |
| 824 | let shape = 0; |
| 825 | let masked = 0; |
| 826 | let masks: BuiltGlass["masks"] | null = null; |
| 827 | let magnification = 1; |
| 828 | let generation = 0; |
| 829 | let box = ""; |
| 830 | let lastBuild = -Infinity; |
| 831 | let timer = 0; |
| 832 | let zoomTimer = 0; |
| 833 | let disposed = false; |
| 834 | // Shaped glass: the build on screen, and the key of the one asked for. |
| 835 | let shaped: BuiltShape | null = null; |
| 836 | let shapeKey = ""; |
| 837 | let shapeTimer = 0; |
| 838 |
| 839 | const geometry = () => options.geometry ?? {}; |
| 840 | // A magnifying lens bends its whole body, so its refraction is unmasked. |
| 841 | const magnifying = () => magnification > 1.0005; |
| 842 |
| 843 | const build = (target: number) => { |
| 844 | lastBuild = performance.now(); |
| 845 | requested = target; |
| 846 | const built = buildGlass(target, pixelRatio, geometry()); |
| 847 | const current = ++generation; |
| 848 | // The current slices stay up until the new ones can be shown masked. |
| 849 | void built.decoded.then(() => { |
| 850 | if (!disposed && current === generation) apply(built); |
| 851 | }); |
| 852 | }; |
| 853 |
| 854 | /** Lays the masks' slices over the box for a corner radius; `images` also swaps them in. */ |
| 855 | const placeMasks = (corner: number, images: boolean) => { |
| 856 | if (!masks || shaped) return; |
| 857 | masked = corner; |
| 858 | for (const mask of MASKS) { |
| 859 | // Unmasked, a layer would filter the whole box. |
| 860 | if (images) layers[mask].hidden = masks[mask].length === 0; |
| 861 | if (layers[mask].hidden) continue; |
| 862 | if (mask === "refract" && magnifying()) { |
| 863 | if (images) clearMask(layers.refract); |
| 864 | continue; |
| 865 | } |
| 866 | const styles = maskStyles(masks[mask], corner); |
| 867 | for (const prefix of ["-webkit-", ""]) { |
| 868 | if (images) layers[mask].style.setProperty(`${prefix}mask-image`, styles.image); |
| 869 | layers[mask].style.setProperty(`${prefix}mask-position`, styles.position); |
| 870 | layers[mask].style.setProperty(`${prefix}mask-size`, styles.size); |
| 871 | if (images) layers[mask].style.setProperty(`${prefix}mask-repeat`, "no-repeat"); |
| 872 | } |
| 873 | } |
| 874 | }; |
| 875 |
| 876 | const startRefracting = () => { |
| 877 | element.dataset.refraction = ""; |
| 878 | if (!refracting) { |
| 879 | refracting = true; |
| 880 | paint(); |
| 881 | } |
| 882 | }; |
| 883 |
| 884 | const apply = (built: BuiltGlass) => { |
| 885 | radius = built.slices.radius; |
| 886 | masks = built.masks; |
| 887 | placeMasks(shape || radius, true); |
| 888 | if (refraction) { |
| 889 | refraction.setMaps(built.refraction, built.slices.scale); |
| 890 | if (element.offsetWidth && element.offsetHeight) { |
| 891 | refraction.fit(element.offsetWidth, element.offsetHeight, shape || radius); |
| 892 | } |
| 893 | startRefracting(); |
| 894 | } |
| 895 | element.dataset.ready = ""; |
| 896 | }; |
| 897 |
| 898 | /** Masks every layer of shaped glass with its build, stretched over the box. */ |
| 899 | const applyShape = (built: BuiltShape) => { |
| 900 | shaped = built; |
| 901 | for (const mask of MASKS) { |
| 902 | layers[mask].hidden = !built.masks[mask]; |
| 903 | if (layers[mask].hidden) continue; |
| 904 | if (mask === "refract" && magnifying()) clearMask(layers.refract); |
| 905 | else setMask(layers[mask], `url("${built.masks[mask]}")`, "0 0", "100% 100%"); |
| 906 | } |
| 907 | if (refraction) { |
| 908 | refraction.setMaps([built.refraction], built.glass.scale); |
| 909 | refraction.fit(element.offsetWidth, element.offsetHeight, 0); |
| 910 | startRefracting(); |
| 911 | } |
| 912 | element.dataset.ready = ""; |
| 913 | }; |
| 914 |
| 915 | const remask = () => { |
| 916 | if (shaped) applyShape(shaped); |
| 917 | else placeMasks(masked || shape || radius, true); |
| 918 | }; |
| 919 |
| 920 | /** Shaped glass: the outline at once, the rim once it is built for this size. */ |
| 921 | const layoutShape = (path: string, width: number, height: number) => { |
| 922 | const mask = outlineMask(path, width, height); |
| 923 | if (mask !== outline) { |
| 924 | outline = mask; |
| 925 | for (const node of [layers.body, hueLayer, light?.element]) if (node) setMask(node, outline, "0 0", "100% 100%"); |
| 926 | } |
| 927 | refraction?.fit(width, height, 0); |
| 928 | const corner = parseFloat(getComputedStyle(element).borderTopLeftRadius) || Math.min(width, height) / 4; |
| 929 | const key = `${path}:${width}x${height}:${buildKey(Math.floor(corner), pixelRatio, geometry())}`; |
| 930 | if (key === shapeKey) return; |
| 931 | shapeKey = key; |
| 932 | window.clearTimeout(shapeTimer); |
| 933 | const cached = shapedBuilds.get(key); |
| 934 | if (cached) { |
| 935 | void cached.decoded.then(() => { |
| 936 | if (!disposed && key === shapeKey) applyShape(cached); |
| 937 | }); |
| 938 | return; |
| 939 | } |
| 940 | // A build takes a few hundred milliseconds for a large surface: one per size, |
| 941 | // once the size holds still and the page is idle. |
| 942 | shapeTimer = window.setTimeout(() => { |
| 943 | idle(() => { |
| 944 | if (disposed || key !== shapeKey) return; |
| 945 | const built = buildShape(path, width, height, Math.floor(corner), pixelRatio, geometry()); |
| 946 | void built.decoded.then(() => { |
| 947 | if (!disposed && key === shapeKey) applyShape(built); |
| 948 | }); |
| 949 | }, 500); |
| 950 | }, REBUILD_INTERVAL); |
| 951 | }; |
| 952 |
| 953 | const shapePath = (width: number, height: number) => { |
| 954 | const option = options.shape; |
| 955 | return typeof option === "function" ? option(width, height) : option; |
| 956 | }; |
| 957 |
| 958 | const layout = (force = false) => { |
| 959 | window.clearTimeout(timer); |
| 960 | timer = 0; |
| 961 | const width = element.offsetWidth; |
| 962 | const height = element.offsetHeight; |
| 963 | if (!width || !height) return; |
| 964 | // Resize callbacks also arrive for unchanged boxes; skip the style read then. |
| 965 | const next = `${width}:${height}`; |
| 966 | if (!force && next === box) return; |
| 967 | box = next; |
| 968 | const path = shapePath(width, height); |
| 969 | if (path) return layoutShape(path, width, height); |
| 970 | const exact = Math.min(parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0, width / 2, height / 2); |
| 971 | const target = Math.floor(exact); |
| 972 | if (target >= 1) shape = exact; |
| 973 | if (target >= 1 && target !== requested) { |
| 974 | const wait = lastBuild + REBUILD_INTERVAL - performance.now(); |
| 975 | // A radius built before costs nothing to swap to, so it isn't held back. |
| 976 | if (wait <= 0 || builds.has(buildKey(target, pixelRatio, geometry()))) build(target); |
| 977 | // Keep the current slices while the radius animates, then catch up. |
| 978 | else timer = window.setTimeout(() => layout(true), wait); |
| 979 | } |
| 980 | if (!radius) return; |
| 981 | if (Math.abs(shape - masked) > 0.01) placeMasks(shape, false); |
| 982 | refraction?.fit(width, height, shape); |
| 983 | }; |
| 984 | const onTransitionEnd = (event: TransitionEvent) => { |
| 985 | if (event.target === surface && event.propertyName.includes("radius")) layout(true); |
| 986 | }; |
| 987 |
| 988 | /** Forgets the lens on screen so the next layout builds it afresh. */ |
| 989 | const relayout = () => { |
| 990 | requested = 0; |
| 991 | shapeKey = ""; |
| 992 | // Into a shape: the rim waits for its build rather than show the slices stretched over it. |
| 993 | if (options.shape && !shaped) delete element.dataset.ready; |
| 994 | if (!options.shape && (shaped || outline)) { |
| 995 | // Back to a rounded box: the outline comes off the layers that filled it. |
| 996 | shaped = null; |
| 997 | outline = ""; |
| 998 | for (const node of [layers.body, hueLayer, light?.element]) if (node) clearMask(node); |
| 999 | for (const mask of MASKS) clearMask(layers[mask]); |
| 1000 | masks = null; |
| 1001 | radius = 0; |
| 1002 | delete element.dataset.ready; |
| 1003 | } |
| 1004 | layout(true); |
| 1005 | }; |
| 1006 |
| 1007 | let samples: 1 | 4 = 4; |
| 1008 | const createRefraction = () => { |
| 1009 | const created = createRefractionFilter(filterId, samples); |
| 1010 | created.magnify(magnification); |
| 1011 | return created; |
| 1012 | }; |
| 1013 | const setRefraction = (enabled: boolean, nextSamples: 1 | 4) => { |
| 1014 | const resample = nextSamples !== samples; |
| 1015 | samples = nextSamples; |
| 1016 | if (enabled === Boolean(refraction) && !(enabled && resample)) return; |
| 1017 | refraction?.remove(); |
| 1018 | refraction = null; |
| 1019 | if (enabled) { |
| 1020 | // The refract layer keeps naming the filter id, which the new filter takes over. |
| 1021 | refraction = createRefraction(); |
| 1022 | relayout(); |
| 1023 | return; |
| 1024 | } |
| 1025 | refracting = false; |
| 1026 | delete element.dataset.refraction; |
| 1027 | paint(); |
| 1028 | }; |
| 1029 |
| 1030 | // Browser zoom changes the pixel ratio; rebuild the slices for it once the zoom |
| 1031 | // settles. The current slices stay up meanwhile. |
| 1032 | let resolution: MediaQueryList | null = null; |
| 1033 | const watchResolution = () => { |
| 1034 | resolution?.removeEventListener("change", onResolutionChange); |
| 1035 | resolution = window.matchMedia(`(resolution: ${devicePixelRatio}dppx)`); |
| 1036 | resolution.addEventListener("change", onResolutionChange); |
| 1037 | }; |
| 1038 | function onResolutionChange() { |
| 1039 | watchResolution(); |
| 1040 | window.clearTimeout(zoomTimer); |
| 1041 | zoomTimer = window.setTimeout(() => { |
| 1042 | const next = slicePixelRatio(); |
| 1043 | if (next === pixelRatio) return; |
| 1044 | pixelRatio = next; |
| 1045 | relayout(); |
| 1046 | }, ZOOM_SETTLE); |
| 1047 | } |
| 1048 |
| 1049 | // ── Fit ── |
| 1050 | // A surface that scrolls itself: the glass spans its scroll height. Content growing |
| 1051 | // inside the surface resizes its children, not the surface, so they are watched too. |
| 1052 | let scrollFit: (() => void) | null = null; |
| 1053 | const spanScroll = () => { |
| 1054 | const fit = () => { |
| 1055 | // The glass is part of the scrollable overflow, so measure without it or the |
| 1056 | // surface could never shrink back. |
| 1057 | element.style.height = "0px"; |
| 1058 | element.style.height = `${surface.scrollHeight}px`; |
| 1059 | }; |
| 1060 | const resize = new ResizeObserver(fit); |
| 1061 | const watch = () => { |
| 1062 | resize.disconnect(); |
| 1063 | resize.observe(surface); |
| 1064 | for (const child of surface.children) { |
| 1065 | if (child !== element) resize.observe(child); |
| 1066 | } |
| 1067 | }; |
| 1068 | const children = new MutationObserver(() => { |
| 1069 | watch(); |
| 1070 | fit(); |
| 1071 | }); |
| 1072 | watch(); |
| 1073 | fit(); |
| 1074 | children.observe(surface, { childList: true }); |
| 1075 | return () => { |
| 1076 | resize.disconnect(); |
| 1077 | children.disconnect(); |
| 1078 | element.style.removeProperty("height"); |
| 1079 | }; |
| 1080 | }; |
| 1081 | const setFit = (fit: LiquidGlassFit) => { |
| 1082 | if ((fit === "scroll") === Boolean(scrollFit)) return; |
| 1083 | if (scrollFit) { |
| 1084 | scrollFit(); |
| 1085 | scrollFit = null; |
| 1086 | } else { |
| 1087 | scrollFit = spanScroll(); |
| 1088 | } |
| 1089 | }; |
| 1090 |
| 1091 | // ── Light ── |
| 1092 | // Interactive glass lights up from within: light added over the glass layers, |
| 1093 | // under the content, since a brightness filter can't lift dark glass, which is |
| 1094 | // near black. It follows the pointer through its own properties: set on the |
| 1095 | // surface they would be inherited, restyling the surface's content on every move. |
| 1096 | const lightUp = () => { |
| 1097 | const node = document.createElement("span"); |
| 1098 | node.className = "liquid-glass-light"; |
| 1099 | element.append(node); |
| 1100 | if (outline) setMask(node, outline, "0 0", "100% 100%"); |
| 1101 | const place = (event: PointerEvent) => { |
| 1102 | const frame = element.getBoundingClientRect(); |
| 1103 | node.style.setProperty("--liquid-glass-x", `${event.clientX - frame.left}px`); |
| 1104 | node.style.setProperty("--liquid-glass-y", `${event.clientY - frame.top}px`); |
| 1105 | }; |
| 1106 | const onEnter = (event: PointerEvent) => { |
| 1107 | if (event.pointerType !== "touch") surface.toggleAttribute("data-liquid-glass-pointer", true); |
| 1108 | }; |
| 1109 | const onLeave = () => surface.removeAttribute("data-liquid-glass-pointer"); |
| 1110 | // Touch only moves the light while pressed; a mouse moves it all the time. |
| 1111 | const onMove = (event: PointerEvent) => { |
| 1112 | if (event.pointerType !== "touch" || surface.hasAttribute("data-liquid-glass-pressed")) place(event); |
| 1113 | }; |
| 1114 | const onRelease = () => { |
| 1115 | window.removeEventListener("pointerup", onRelease); |
| 1116 | window.removeEventListener("pointercancel", onRelease); |
| 1117 | surface.removeAttribute("data-liquid-glass-pressed"); |
| 1118 | }; |
| 1119 | const onDown = (event: PointerEvent) => { |
| 1120 | if (event.button !== 0) return; |
| 1121 | place(event); |
| 1122 | surface.toggleAttribute("data-liquid-glass-pressed", true); |
| 1123 | window.addEventListener("pointerup", onRelease); |
| 1124 | window.addEventListener("pointercancel", onRelease); |
| 1125 | }; |
| 1126 | surface.addEventListener("pointerenter", onEnter); |
| 1127 | surface.addEventListener("pointerleave", onLeave); |
| 1128 | surface.addEventListener("pointermove", onMove); |
| 1129 | surface.addEventListener("pointerdown", onDown); |
| 1130 | return { |
| 1131 | element: node, |
| 1132 | remove() { |
| 1133 | surface.removeEventListener("pointerenter", onEnter); |
| 1134 | surface.removeEventListener("pointerleave", onLeave); |
| 1135 | surface.removeEventListener("pointermove", onMove); |
| 1136 | surface.removeEventListener("pointerdown", onDown); |
| 1137 | onRelease(); |
| 1138 | onLeave(); |
| 1139 | node.remove(); |
| 1140 | }, |
| 1141 | }; |
| 1142 | }; |
| 1143 | const setInteractive = (enabled: boolean) => { |
| 1144 | if (enabled === Boolean(light)) return; |
| 1145 | if (enabled) light = lightUp(); |
| 1146 | else { |
| 1147 | light?.remove(); |
| 1148 | light = null; |
| 1149 | } |
| 1150 | }; |
| 1151 |
| 1152 | // ── Swell ── |
| 1153 | let swelling: (() => void) | null = null; |
| 1154 | const setSwell = (enabled: boolean) => { |
| 1155 | if (enabled === Boolean(swelling)) return; |
| 1156 | if (enabled) swelling = swellOnPress(surface); |
| 1157 | else { |
| 1158 | swelling?.(); |
| 1159 | swelling = null; |
| 1160 | } |
| 1161 | }; |
| 1162 |
| 1163 | // ── Pouring ── |
| 1164 | let morph: ReturnType<typeof createGlassMorph> | null = null; |
| 1165 |
| 1166 | // Repaint when the appearance this surface follows flips, and only then. |
| 1167 | const onAppearanceSignal = () => { |
| 1168 | if (resolveAppearance(options.appearance) !== appearance) paint(); |
| 1169 | }; |
| 1170 |
| 1171 | const animate = (direction: "in" | "out", timing: GlassTiming = {}) => { |
| 1172 | for (const animation of animations) animation.cancel(); |
| 1173 | const run = ++animating; |
| 1174 | // Glass eases in and out evenly (ease-out cubic): on a quint curve nearly half |
| 1175 | // of an opening landed in its first frame at 60 fps, and most of a closing in |
| 1176 | // its first two, so it seemed to appear and vanish rather than come and go. |
| 1177 | const effect = { |
| 1178 | duration: timing.duration ?? (direction === "in" ? 240 : 180), |
| 1179 | easing: timing.easing ?? "cubic-bezier(0.33, 1, 0.68, 1)", |
| 1180 | delay: timing.delay ?? 0, |
| 1181 | fill: direction === "in" ? "backwards" : "forwards", |
| 1182 | } satisfies KeyframeAnimationOptions; |
| 1183 | // The refraction holds still while the rest materializes; it goes once the glass has. |
| 1184 | if (direction === "in") layers.refract.style.removeProperty("visibility"); |
| 1185 | const nodes = LAYERS.filter(name => name !== "refract").map(name => layers[name]); |
| 1186 | animations = nodes.map(node => { |
| 1187 | const full = node.style.getPropertyValue("--liquid-glass-full"); |
| 1188 | const clear = node.style.getPropertyValue("--liquid-glass-clear"); |
| 1189 | const wash = node.style.getPropertyValue("--liquid-glass-wash") || "transparent"; |
| 1190 | const from = { backdropFilter: clear, webkitBackdropFilter: clear, backgroundColor: "transparent" }; |
| 1191 | const to = { backdropFilter: full, webkitBackdropFilter: full, backgroundColor: wash }; |
| 1192 | return node.animate(direction === "in" ? [from, to] : [to, from], effect); |
| 1193 | }); |
| 1194 | if (timing.scale !== undefined && timing.scale !== 1) { |
| 1195 | const inset = `${((1 - timing.scale) * 50).toFixed(3)}%`; |
| 1196 | const keyframes = [{ inset }, { inset: "0px" }]; |
| 1197 | animations.push(element.animate(direction === "in" ? keyframes : keyframes.reverse(), effect)); |
| 1198 | } |
| 1199 | // Floating glass's shadow grows in and out with it; left as it was, it hung |
| 1200 | // around a cleared surface as a dark outline until the surface went. |
| 1201 | const shadow = options.floating ? getComputedStyle(surface).boxShadow : "none"; |
| 1202 | if (shadow && shadow !== "none") { |
| 1203 | const keyframes = [{ boxShadow: "none" }, { boxShadow: shadow }]; |
| 1204 | animations.push(surface.animate(direction === "in" ? keyframes : keyframes.reverse(), effect)); |
| 1205 | } |
| 1206 | // A well on the glass can't fade with the surface's contents either, so it |
| 1207 | // eases back to clear glass with the rest (styles.css). |
| 1208 | surface.toggleAttribute("data-liquid-glass-leaving", direction === "out"); |
| 1209 | return Promise.all(animations.map(animation => animation.finished.catch(() => undefined))).then(() => { |
| 1210 | if (direction === "out" && run === animating && !disposed) layers.refract.style.visibility = "hidden"; |
| 1211 | }); |
| 1212 | }; |
| 1213 |
| 1214 | paint(); |
| 1215 | setFit(options.fit ?? "box"); |
| 1216 | setInteractive(Boolean(options.interactive)); |
| 1217 | setSwell(options.swell ?? Boolean(options.interactive)); |
| 1218 | samples = options.samples === 1 ? 1 : 4; |
| 1219 | if (options.refraction !== false && supportsRefraction()) refraction = createRefraction(); |
| 1220 | const observer = new ResizeObserver(() => layout()); |
| 1221 | observer.observe(element); |
| 1222 | surface.addEventListener("transitionend", onTransitionEnd); |
| 1223 | const unwatchAppearance = watchAppearance(onAppearanceSignal); |
| 1224 | watchResolution(); |
| 1225 | // Menus and popovers pour out of their buttons through corners from 4 to 48 px. |
| 1226 | if (options.floating && !options.shape) prewarmMorph(geometry()); |
| 1227 |
| 1228 | return { |
| 1229 | element, |
| 1230 | get appearance() { |
| 1231 | return appearance; |
| 1232 | }, |
| 1233 | update(next) { |
| 1234 | const rebuild = |
| 1235 | JSON.stringify(next.geometry ?? {}) !== JSON.stringify(options.geometry ?? {}) || |
| 1236 | Boolean(next.shape) !== Boolean(options.shape) || |
| 1237 | (typeof next.shape === "string" && next.shape !== options.shape); |
| 1238 | options = next; |
| 1239 | paint(); |
| 1240 | setFit(next.fit ?? "box"); |
| 1241 | setInteractive(Boolean(next.interactive)); |
| 1242 | setSwell(next.swell ?? Boolean(next.interactive)); |
| 1243 | setRefraction(next.refraction !== false && supportsRefraction(), next.samples === 1 ? 1 : 4); |
| 1244 | if (rebuild) relayout(); |
| 1245 | }, |
| 1246 | magnify(factor) { |
| 1247 | const was = magnifying(); |
| 1248 | magnification = factor; |
| 1249 | refraction?.magnify(factor); |
| 1250 | if (magnifying() !== was) remask(); |
| 1251 | }, |
| 1252 | materialize: timing => animate("in", timing), |
| 1253 | dematerialize: timing => animate("out", timing), |
| 1254 | pourFrom(source, pour) { |
| 1255 | if (!options.shape) prewarmMorph(geometry()); |
| 1256 | // It is glass the whole way, as iOS 27's is: every layer stays at full |
| 1257 | // strength while it pours out and draws back, instead of materializing. |
| 1258 | for (const animation of animations) animation.cancel(); |
| 1259 | animations = []; |
| 1260 | animating++; |
| 1261 | surface.removeAttribute("data-liquid-glass-leaving"); |
| 1262 | layers.refract.style.removeProperty("visibility"); |
| 1263 | morph ??= createGlassMorph(element, surface); |
| 1264 | return morph.pour(source, pour); |
| 1265 | }, |
| 1266 | drawBack() { |
| 1267 | return morph ? morph.drawBack() : Promise.resolve(true); |
| 1268 | }, |
| 1269 | destroy() { |
| 1270 | disposed = true; |
| 1271 | window.clearTimeout(timer); |
| 1272 | window.clearTimeout(shapeTimer); |
| 1273 | window.clearTimeout(zoomTimer); |
| 1274 | observer.disconnect(); |
| 1275 | surface.removeEventListener("transitionend", onTransitionEnd); |
| 1276 | unwatchAppearance(); |
| 1277 | resolution?.removeEventListener("change", onResolutionChange); |
| 1278 | for (const animation of animations) animation.cancel(); |
| 1279 | morph?.destroy(); |
| 1280 | morph = null; |
| 1281 | scrollFit?.(); |
| 1282 | scrollFit = null; |
| 1283 | setInteractive(false); |
| 1284 | setSwell(false); |
| 1285 | refraction?.remove(); |
| 1286 | hueLayer?.remove(); |
| 1287 | delete element.dataset.ready; |
| 1288 | delete element.dataset.refraction; |
| 1289 | delete surface.dataset.liquidGlass; |
| 1290 | surface.removeAttribute("data-liquid-glass-floating"); |
| 1291 | surface.removeAttribute("data-liquid-glass-interactive"); |
| 1292 | surface.removeAttribute("data-liquid-glass-leaving"); |
| 1293 | surface.style.removeProperty("--liquid-glass-solid"); |
| 1294 | surface.style.removeProperty("--liquid-glass-level"); |
| 1295 | }, |
| 1296 | }; |
| 1297 | } |
| 1298 |
| 1299 | /** |
| 1300 | * Turns any positioned, rounded element into a glass surface. The glass is inserted |
| 1301 | * as its first child and removed again by `destroy()`. |
| 1302 | * |
| 1303 | * ```ts |
| 1304 | * const glass = createLiquidGlass(document.querySelector(".toolbar")!); |
| 1305 | * ``` |
| 1306 | */ |
| 1307 | export function createLiquidGlass(surface: HTMLElement, options: LiquidGlassOptions = {}): LiquidGlass { |
| 1308 | const element = document.createElement("span"); |
| 1309 | surface.prepend(element); |
| 1310 | const glass = attachLiquidGlass(element, options); |
| 1311 | return { |
| 1312 | element, |
| 1313 | get appearance() { |
| 1314 | return glass.appearance; |
| 1315 | }, |
| 1316 | update: glass.update, |
| 1317 | magnify: glass.magnify, |
| 1318 | materialize: glass.materialize, |
| 1319 | dematerialize: glass.dematerialize, |
| 1320 | pourFrom: glass.pourFrom, |
| 1321 | drawBack: glass.drawBack, |
| 1322 | destroy() { |
| 1323 | glass.destroy(); |
| 1324 | element.remove(); |
| 1325 | }, |
| 1326 | }; |
| 1327 | } |
| 1328 |