import { createGlassSlices, createShapedGlass, DEFAULT_GEOMETRY, MASKS, SLICES, sliceRects, type GlassGeometry, type GlassMask, type GlassSlices, type ShapedGlass, } from "./lens.js"; import { createGlassMorph, MORPH_RADII, type GlassPourOptions, type GlassPourSource } from "./morph.js"; import { swellOnPress } from "./swell.js"; import { DEFAULT_HUE_STRENGTH, DEFAULT_TINT, hueMatrix, resolveTone, toneFilters, toneWash, type GlassAppearance, type GlassFilter, type GlassToneOverrides, type GlassVariant, } from "./tone.js"; /* Liquid Glass after iOS 27, without a frosted fill. Every layer is the page behind the glass, filtered: a body that diffuses it, the refraction bending it at the rim, then darkened sides, an inner glow, a darkened outer edge and a rim hairline gathered into glints, each masked to a point-wide band of the lens. Rim refraction needs SVG filters in backdrop-filter, which only Chromium renders; WebKit and Gecko would drop the whole backdrop, so they keep the glass without the bend. Filters are set inline as literal values because Safari ignores custom properties in -webkit-backdrop-filter. */ const SVG_NS = "http://www.w3.org/2000/svg"; // A resizing radius (a springing capsule, an opening menu) rebuilds at most this often. const REBUILD_INTERVAL = 100; // How long the pixel ratio must hold still before a zoom counts as finished. const ZOOM_SETTLE = 150; const LAYERS = ["body", "refract", "shade", "glow", "edge", "highlight"] as const; type GlassLayer = (typeof LAYERS)[number]; /** * How the glass covers its surface: * - `box`: fills the surface. For surfaces that don't scroll themselves. * - `fixed`: fills the nearest containing block for fixed elements, such as a * transformed popper wrapper, so the glass holds still while the surface * scrolls its own content. * - `scroll`: spans the surface's whole scroll height, so a surface that scrolls * itself (a dialog, a sheet) stays glass all the way down. */ export type LiquidGlassFit = "box" | "fixed" | "scroll"; export type LiquidGlassOptions = { /** * - `system` (default): follows prefers-color-scheme. * - `class`: dark while `` has the `dark` class, as with Tailwind's class * strategy and next-themes. */ appearance?: GlassAppearance | "system" | "class" | undefined; variant?: GlassVariant | undefined; /** * How clear or tinted regular glass is, as iOS 27's Liquid Glass slider sets it: * 0 is More Clear, 0.5 (the default) a light frost and a faint wash, 1 More Tinted. * Anything in between. The rim doesn't change with it. */ level?: number | undefined; /** Per-appearance overrides of the variant's tone. */ tone?: GlassToneOverrides | undefined; geometry?: Partial | undefined; /** * Glass of an outline no rounded rectangle makes: an SVG path of absolute M, L, H, * V, C and Z commands in the surface's CSS pixels, or a function from its size to * one. The glass is rendered whole for each size, which takes a few hundred * milliseconds for a large surface, so keep shaped glass to surfaces that don't * resize. Its rims stay within the surface's border-radius. */ shape?: string | ((width: number, height: number) => string) | undefined; /** Colour washed over the body, by the tone's `tint` share. Any CSS colour, including var(). */ tint?: string | undefined; /** A colour whose hue the glass takes on, like tinted iOS glass. Any concrete CSS colour. */ hue?: string | null | undefined; /** How much of the hue the glass takes (0–1). Defaults to 1 on light glass and 0.5 on dark. */ hueStrength?: number | undefined; fit?: LiquidGlassFit | undefined; /** Bend the page at the rim, where the browser supports it. On by default. */ refraction?: boolean | undefined; /** * Refraction samples per pixel: 4 (the default) or 1. Chromium's * feDisplacementMap takes the nearest backdrop pixel, so wherever the glass * stretches or compresses what is beneath, edges stair-step and crawl as it * moves. Four samples a quarter of a device pixel apart smooth them. They cost * four displacement passes; 1 is cheaper for large glass. */ samples?: 1 | 4 | undefined; /** * For menus, popovers, dialogs and sheets. Floating glass diffuses the page more, * as iOS 27's does, so text beneath its own text washes into soft colour instead * of reading through, and the surface casts a drop shadow (--liquid-glass-shadow). * `"dialog"` casts a dialog's deeper shadow (--liquid-glass-dialog-shadow). */ floating?: boolean | "dialog" | undefined; /** * For glass that holds controls, like SwiftUI's `.glassEffect(.regular.interactive())`: * the pointer warms the glass beneath it, a press blooms out from there while the * glass swells and springs back, and light glass floats on a faint shadow * (--liquid-glass-control-shadow). */ interactive?: boolean | undefined; /** * Pressed, the surface swells about its centre and springs back with a slight * overshoot on release; held and dragged, it leans after the pointer like taffy. * On by default for interactive glass. For glass you press, like a search field. */ swell?: boolean | undefined; }; export type GlassTiming = { /** Milliseconds. */ duration?: number | undefined; easing?: string | undefined; delay?: number | undefined; /** * Grow the glass from (or shrink it to) this scale of the surface, for surfaces * that zoom as they open and close. The glass changes size through its inset, * since scaling it blanks it; scale the surface's contents, not the surface. */ scale?: number | undefined; }; export type LiquidGlass = { /** The glass element: the first child of the surface. */ readonly element: HTMLElement; /** The resolved appearance. */ readonly appearance: GlassAppearance; update(options: LiquidGlassOptions): void; /** * Magnifies what is beneath the glass about its centre by `factor` (1 for none), * as a raised lens does. Cheap enough to call every frame. Needs refraction. */ magnify(factor: number): void; /** Grows the glass from clear to its tone, for surfaces that appear. */ materialize(timing?: GlassTiming): Promise; /** Fades the glass back to clear and holds it there, for surfaces that leave. */ dematerialize(timing?: GlassTiming): Promise; /** * Pours the surface out of the button that opened it, or a point, as iOS 27's * menus do: the glass starts as the button and inflates into the surface, and the * contents ride inside it. Call it once the surface is shown and placed. Resolves * true once it has landed, or false if drawBack() interrupted it. */ pourFrom(source: GlassPourSource, options?: GlassPourOptions): Promise; /** * Draws the surface back into where it poured from: the contents go first and the * glass shrinks into the button as a drop. Resolves true once the glass has gone, * when you should hide the surface, or false if pourFrom() reopened it. */ drawBack(): Promise; destroy(): void; }; // ── Slices ────────────────────────────────────────────── function imageUrl(pixels: Uint8ClampedArray, width: number, height: number) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; canvas.getContext("2d")?.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0); return canvas.toDataURL(); } function sliceUrls(pixels: Uint8ClampedArray, slices: GlassSlices) { const source = document.createElement("canvas"); source.width = slices.size; source.height = slices.size; source.getContext("2d")?.putImageData(new ImageData(new Uint8ClampedArray(pixels), slices.size, slices.size), 0, 0); const piece = document.createElement("canvas"); const context = piece.getContext("2d"); return sliceRects(slices).map(([x, y, width, height]) => { piece.width = width; piece.height = height; context?.drawImage(source, x, y, width, height, 0, 0, width, height); return piece.toDataURL(); }); } type BuiltGlass = { slices: GlassSlices; masks: Record; refraction: string[]; /** Resolves once every slice is decoded; see decodeImages. */ decoded: Promise; }; // Chromium paints a masked layer without its mask until the mask image has decoded, // and the rim layers brighten everything they cover, so a fresh mask flashes the // whole surface light. Images are decoded before use and kept for as long as their // build is cached, so masks and feImages that name these URLs find them ready. const decodedImages = new WeakMap(); function decodeImages(urls: string[]) { const images = urls.map(url => { const image = new Image(); image.src = url; return image; }); return { images, decoded: Promise.all(images.map(image => image.decode().catch(() => {}))).then(() => {}) }; } // A build is a pure function of radius, pixel ratio and geometry, and surfaces flip // between a few sizes (full, compact, open), so repeat builds come from here instead // of re-rendering the lens and re-encoding 40 slices mid-animation. There is room // for a few kinds of glass on a page plus every radius a pouring menu and a tab // lens pass through (prewarmLiquidGlass). const builds = new Map(); const BUILD_CACHE_SIZE = 96; const buildKey = (radius: number, pixelRatio: number, geometry: Partial) => `${radius}:${pixelRatio}:${JSON.stringify(geometry)}`; /** * The pixel ratio slices are built at: the screen's, rounded up to whole pixels, up * to 3. Zoomed to 110% a 2x screen is 2.2x, and slices built at 2 were stretched. */ function slicePixelRatio() { return Math.min(Math.ceil(devicePixelRatio), 3); } function buildGlass(radius: number, pixelRatio: number, geometry: Partial) { const key = buildKey(radius, pixelRatio, geometry); const cached = builds.get(key); if (cached) { // Most recently used last, so eviction takes the build that has gone longest unused. builds.delete(key); builds.set(key, cached); return cached; } const slices = createGlassSlices(radius, pixelRatio, geometry); const urls = { slices, masks: { highlight: sliceUrls(slices.masks.highlight, slices), shade: sliceUrls(slices.masks.shade, slices), glow: sliceUrls(slices.masks.glow, slices), // Glass without a darkened edge has no edge layer to mask. edge: (geometry.edge ?? DEFAULT_GEOMETRY.edge) > 0 ? sliceUrls(slices.masks.edge, slices) : [], refract: sliceUrls(slices.masks.refract, slices), }, refraction: sliceUrls(slices.refraction, slices), }; const { images, decoded } = decodeImages([...MASKS.flatMap(mask => urls.masks[mask]), ...urls.refraction]); const built: BuiltGlass = { ...urls, decoded }; decodedImages.set(built, images); builds.set(key, built); if (builds.size > BUILD_CACHE_SIZE) builds.delete(builds.keys().next().value ?? ""); return built; } const idle = (callback: () => void, timeout: number) => { // Safari has no requestIdleCallback. if (typeof window.requestIdleCallback === "function") window.requestIdleCallback(callback, { timeout }); else window.setTimeout(callback, 16); }; /** * Builds the glass for these corner radii ahead of time, while the page is idle, * so glass whose corners animate through them swaps to each at once instead of * stretching the last build over it. By default, every radius a menu passes * through as it pours out of its button (pourFrom), for glass of `geometry`. * Floating glass does this by itself the first time it appears; call it at startup * if your menus mount only as they open, so the first one pours out sharp. */ export function prewarmLiquidGlass(radii: readonly number[] = MORPH_RADII, geometry: Partial = {}) { if (typeof window === "undefined") return; const pixelRatio = slicePixelRatio(); const queue = radii .map(radius => Math.floor(radius)) .filter(radius => radius >= 1 && !builds.has(buildKey(radius, pixelRatio, geometry))); // One build per idle moment: each takes a few milliseconds, and twenty at once // held the page for over a tenth of a second. const next = () => { const radius = queue.shift(); if (radius === undefined) return; buildGlass(radius, pixelRatio, geometry); if (queue.length) idle(next, 2000); }; idle(next, 4000); } // Geometries whose morph radii are built or on their way. const prewarmed = new Set(); function prewarmMorph(geometry: Partial) { const key = JSON.stringify(geometry); if (prewarmed.has(key)) return; prewarmed.add(key); prewarmLiquidGlass(MORPH_RADII, geometry); } type BuiltShape = { glass: ShapedGlass; masks: Record; refraction: string; decoded: Promise; }; const shapedBuilds = new Map(); // Each size of each shape builds its own; keep the few in use. const SHAPED_CACHE_SIZE = 12; function buildShape(path: string, width: number, height: number, radius: number, pixelRatio: number, geometry: Partial) { const key = `${path}:${width}x${height}:${buildKey(radius, pixelRatio, geometry)}`; const cached = shapedBuilds.get(key); if (cached) return cached; const glass = createShapedGlass(path, width, height, radius, pixelRatio, geometry); const url = (pixels: Uint8ClampedArray) => imageUrl(pixels, glass.width, glass.height); const masks = { highlight: url(glass.masks.highlight), shade: url(glass.masks.shade), glow: url(glass.masks.glow), edge: (geometry.edge ?? DEFAULT_GEOMETRY.edge) > 0 ? url(glass.masks.edge) : "", refract: url(glass.masks.refract), }; const refraction = url(glass.refraction); const { images, decoded } = decodeImages([...Object.values(masks).filter(Boolean), refraction]); const built: BuiltShape = { glass, masks, refraction, decoded }; decodedImages.set(built, images); shapedBuilds.set(key, built); if (shapedBuilds.size > SHAPED_CACHE_SIZE) shapedBuilds.delete(shapedBuilds.keys().next().value ?? ""); return built; } /** CSS mask layers that assemble the slices over any box. */ function maskStyles(urls: string[], radius: number) { const r = `${radius}px`; const span = `calc(100% - ${radius * 2}px)`; return { image: urls.map(url => `url("${url}")`).join(", "), position: `0 0, 100% 0, 0 100%, 100% 100%, ${r} 0, ${r} 100%, 0 ${r}, 100% ${r}`, size: `${r} ${r}, ${r} ${r}, ${r} ${r}, ${r} ${r}, ${span} ${r}, ${span} ${r}, ${r} ${span}, ${r} ${span}`, }; } function setMask(node: HTMLElement, image: string, position: string, size: string) { for (const prefix of ["-webkit-", ""]) { node.style.setProperty(`${prefix}mask-image`, image); node.style.setProperty(`${prefix}mask-position`, position); node.style.setProperty(`${prefix}mask-size`, size); node.style.setProperty(`${prefix}mask-repeat`, "no-repeat"); } } function clearMask(node: HTMLElement) { for (const prefix of ["-webkit-", ""]) { for (const property of ["mask-image", "mask-position", "mask-size", "mask-repeat"]) node.style.removeProperty(`${prefix}${property}`); } } // ── Filters ───────────────────────────────────────────── /** True where SVG filters work inside backdrop-filter. */ export function supportsRefraction() { if (typeof navigator === "undefined") return false; const brands = (navigator as Navigator & { userAgentData?: { brands: { brand: string }[] } }) .userAgentData?.brands; if (brands) return brands.some(({ brand }) => brand === "Chromium"); // userAgentData only exists on secure origins; plain-http pages fall back to the // user agent string. Chrome on iOS is WebKit and says "CriOS", not "Chrome/". return /Chrome\//.test(navigator.userAgent); } let instances = 0; function uniqueId(prefix: string) { instances += 1; return `${prefix}-${instances.toString(36)}${Math.random().toString(36).slice(2, 7)}`; } function hiddenSvg() { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); // display: none would disable the filter; keep it rendered but out of flow. svg.style.position = "absolute"; svg.style.pointerEvents = "none"; return svg; } // Device pixels from the pixel centre to each of the four refraction samples. const SAMPLE_OFFSET = 0.25; let magnificationField: { url: string; decoded: Promise; image: HTMLImageElement } | null = null; /** * A map whose red and green run from 0 to 1 across and down it. Stretched over * the glass, it is a displacement that grows linearly from the centre: a lens. * Decoded before use, like the slices, and kept for the page's lifetime. */ function magnificationRamp() { if (magnificationField) return magnificationField; const size = 256; const pixels = new Uint8ClampedArray(size * size * 4); for (let row = 0; row < size; row++) { for (let column = 0; column < size; column++) { const index = (row * size + column) * 4; pixels[index] = column; pixels[index + 1] = row; pixels[index + 2] = 128; pixels[index + 3] = 255; } } const url = imageUrl(pixels, size, size); const image = new Image(); image.src = url; magnificationField = { url, image, decoded: image.decode().catch(() => {}) }; return magnificationField; } /** * An SVG filter that assembles refraction slices (or one whole map, for shaped * glass) into a displacement map, adds a magnifying field over the whole box while * magnifying, and displaces the backdrop by it with one sample per pixel or four. */ function createRefractionFilter(id: string, samples: 1 | 4) { const svg = hiddenSvg(); const filter = document.createElementNS(SVG_NS, "filter"); filter.id = id; filter.setAttribute("x", "0"); filter.setAttribute("y", "0"); filter.setAttribute("filterUnits", "userSpaceOnUse"); filter.setAttribute("primitiveUnits", "userSpaceOnUse"); filter.setAttribute("color-interpolation-filters", "sRGB"); // Uncovered map pixels stay neutral rather than displacing by half the scale. const neutral = document.createElementNS(SVG_NS, "feFlood"); neutral.setAttribute("flood-color", "rgb(128, 128, 128)"); neutral.setAttribute("result", "neutral"); const merge = document.createElementNS(SVG_NS, "feMerge"); merge.setAttribute("result", "map"); const neutralNode = document.createElementNS(SVG_NS, "feMergeNode"); neutralNode.setAttribute("in", "neutral"); const images = SLICES.map(name => { const image = document.createElementNS(SVG_NS, "feImage"); image.setAttribute("preserveAspectRatio", "none"); image.setAttribute("result", `slice-${name}`); return image; }); const mergeNodes = SLICES.map(name => { const node = document.createElementNS(SVG_NS, "feMergeNode"); node.setAttribute("in", `slice-${name}`); return node; }); const primitive = (name: string, attributes: Record) => { const node = document.createElementNS(SVG_NS, name); for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); return node; }; // Magnifying, the rim's map ("rim") and the field scaled to the magnification // ("field") add up to the map. Both share the displacement's one scale. const field = primitive("feImage", { x: "0", y: "0", preserveAspectRatio: "none", result: "field-ramp" }); const fieldScale = primitive("feColorMatrix", { in: "field-ramp", type: "matrix", result: "field" }); const combine = primitive("feComposite", { in: "rim", in2: "field", operator: "arithmetic", k1: "0", k3: "1", result: "map" }); // Magnified, what is beneath is resampled and comes out soft, worst on 1x // screens; a light sharpen, growing with the magnification, restores it. const sharpen = primitive("feConvolveMatrix", { order: "3", divisor: "1", preserveAlpha: "true", edgeMode: "duplicate" }); // Four samples read the map shifted a quarter of a device pixel each way and // average what they displace. const four = samples === 4; const shifts = four ? [0, 1, 2, 3].map(index => primitive("feColorMatrix", { in: "map", type: "matrix", result: `map-${index}` })) : []; const displacements = (four ? [0, 1, 2, 3] : [0]).map(index => primitive("feDisplacementMap", { in: "SourceGraphic", in2: four ? `map-${index}` : "map", xChannelSelector: "R", yChannelSelector: "G", result: `sample-${index}`, }), ); const average = (a: string, b: string, result?: string) => primitive("feComposite", { in: a, in2: b, operator: "arithmetic", k2: "0.5", k3: "0.5", ...(result ? { result } : {}) }); const averages = four ? [average("sample-0", "sample-1", "pair-0"), average("sample-2", "sample-3", "pair-1"), average("pair-0", "pair-1")] : []; svg.append(filter); document.body.append(svg); let rimScale = 0; let magnification = 1; let fieldRequested = false; let fieldReady = false; let width = 0; let height = 0; let removed = false; const magnifying = () => fieldReady && magnification > 1.0005; // The filter runs every frame the page moves beneath the glass, so it only carries // the primitives the current box needs. A capsule's corners and top and bottom edges // tile it completely, leaving the side slices empty and the neutral fill unseen. let structure = ""; let used: boolean[] = []; let covered = false; const restructure = () => { const lens = magnifying(); // Where slices meet off the pixel grid, the map is partly transparent. // Displacement reads it unpremultiplied and doesn't mind, but adding the // field would shift that row, so a magnifying lens keeps the neutral fill. const flood = !covered || lens; const key = `${used.join()}:${flood}:${lens}`; if (key === structure) return; structure = key; const keep = (items: Item[]) => items.filter((_, index) => used[index]); merge.setAttribute("result", lens ? "rim" : "map"); merge.replaceChildren(...(flood ? [neutralNode] : []), ...keep(mergeNodes)); filter.replaceChildren( ...(flood ? [neutral] : []), ...keep(images), merge, ...(lens ? [field, fieldScale, combine] : []), ...displacements.flatMap((displacement, index) => [...(shifts[index] ? [shifts[index]] : []), displacement]), ...averages, ...(lens ? [sharpen] : []), ); }; // Writes the values that follow the box, the magnification and the pixel ratio. const update = () => { // Magnifying by m, a point d from the centre shows the backdrop d / m from it. const pull = magnifying() ? 1 - 1 / magnification : 0; const scale = rimScale + pull * Math.max(width, height); for (const displacement of displacements) displacement.setAttribute("scale", scale.toFixed(3)); if (magnifying() && scale > 0) { const share = rimScale / scale; combine.setAttribute("k2", share.toFixed(5)); combine.setAttribute("k4", (-share / 2).toFixed(5)); const x = (-pull * width) / scale; const y = (-pull * height) / scale; 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`); // Tuned by eye at 16%: 0.2 on 1x screens, less at 2x, where it is softened less. const amount = (magnification - 1) * ((window.devicePixelRatio || 1) >= 1.5 ? 0.85 : 1.25); sharpen.setAttribute("kernelMatrix", `0 ${-amount} 0 ${-amount} ${1 + 4 * amount} ${-amount} 0 ${-amount} 0`); } if (shifts.length && scale > 0) { const offset = SAMPLE_OFFSET / (window.devicePixelRatio || 1) / scale; const signs = [[1, 1], [-1, 1], [1, -1], [-1, -1]] as const; shifts.forEach((shift, index) => { const [sx, sy] = signs[index] ?? [0, 0]; 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`); }); } }; const refresh = () => { restructure(); update(); }; return { /** The slices' images, in SLICES order, or one whole map for shaped glass. */ setMaps(urls: string[], scale: number) { images.forEach((image, index) => image.setAttribute("href", urls[index] ?? "")); rimScale = scale; update(); }, /** * Places the slices over the current box; cheap enough for every resize. A * radius of 0 lays the first map over the whole box, as shaped glass has. */ fit(boxWidth: number, boxHeight: number, radius: number) { width = boxWidth; height = boxHeight; const span = Math.max(0, width - radius * 2); const tall = Math.max(0, height - radius * 2); const rects = radius > 0 ? [ [0, 0, radius, radius], [width - radius, 0, radius, radius], [0, height - radius, radius, radius], [width - radius, height - radius, radius, radius], [radius, 0, span, radius], [radius, height - radius, span, radius], [0, radius, radius, tall], [width - radius, radius, radius, tall], ] : [[0, 0, width, height]]; used = SLICES.map((_, index) => { const [, , w = 0, h = 0] = rects[index] ?? []; return w > 0 && h > 0; }); covered = radius <= 0 || tall === 0; filter.setAttribute("width", String(width)); filter.setAttribute("height", String(height)); images.forEach((image, index) => { const [x = 0, y = 0, w = 0, h = 0] = rects[index] ?? []; image.setAttribute("x", String(x)); image.setAttribute("y", String(y)); image.setAttribute("width", String(w)); image.setAttribute("height", String(h)); }); field.setAttribute("width", String(width)); field.setAttribute("height", String(height)); refresh(); }, /** Magnifies what is beneath the glass by `factor` (1 for none). */ magnify(factor: number) { if (factor > 1 && !fieldRequested) { // The field is decoded before its first use, like the slices. fieldRequested = true; const ramp = magnificationRamp(); field.setAttribute("href", ramp.url); void ramp.decoded.then(() => { if (removed) return; fieldReady = true; refresh(); }); } if (Math.abs(factor - magnification) < 0.0005) return; magnification = factor; refresh(); }, remove() { removed = true; svg.remove(); }, }; } // Hue filters are shared: every surface with the same hue and strength names one. let hueDefs: SVGSVGElement | null = null; const hueFilters = new Map(); function hueFilterId(matrix: string) { const existing = hueFilters.get(matrix); if (existing && document.getElementById(existing)) return existing; if (!hueDefs?.isConnected) { hueDefs = hiddenSvg(); document.body.append(hueDefs); } const id = uniqueId("liquid-glass-hue"); const filter = document.createElementNS(SVG_NS, "filter"); filter.id = id; filter.setAttribute("color-interpolation-filters", "sRGB"); const colour = document.createElementNS(SVG_NS, "feColorMatrix"); colour.setAttribute("type", "matrix"); colour.setAttribute("values", matrix); filter.append(colour); hueDefs.append(filter); hueFilters.set(matrix, id); return id; } let colourProbe: CanvasRenderingContext2D | null = null; /** Any CSS colour as sRGB channels, by painting one pixel. */ function parseColour(colour: string): [number, number, number] | null { colourProbe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!colourProbe) return null; colourProbe.canvas.width = 1; colourProbe.canvas.height = 1; colourProbe.clearRect(0, 0, 1, 1); colourProbe.fillStyle = "#000"; colourProbe.fillStyle = colour; colourProbe.fillRect(0, 0, 1, 1); const [r = 0, g = 0, b = 0] = colourProbe.getImageData(0, 0, 1, 1).data; return [r, g, b]; } // ── Appearance ────────────────────────────────────────── const DARK_QUERY = "(prefers-color-scheme: dark)"; export function resolveAppearance(appearance: LiquidGlassOptions["appearance"]): GlassAppearance { if (appearance === "dark" || appearance === "light") return appearance; if (appearance === "class") return document.documentElement.classList.contains("dark") ? "dark" : "light"; return matchMedia(DARK_QUERY).matches ? "dark" : "light"; } // One observer serves every surface that follows the root's class. const classListeners = new Set<() => void>(); let classObserver: MutationObserver | null = null; function watchRootClass(listener: () => void) { classListeners.add(listener); if (!classObserver) { classObserver = new MutationObserver(() => { for (const notify of classListeners) notify(); }); classObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); } return () => { classListeners.delete(listener); if (classListeners.size === 0) { classObserver?.disconnect(); classObserver = null; } }; } /** Calls back whenever a `system` or `class` appearance may have flipped. */ export function watchAppearance(listener: () => void) { const system = matchMedia(DARK_QUERY); system.addEventListener("change", listener); const unwatchRootClass = watchRootClass(listener); return () => { system.removeEventListener("change", listener); unwatchRootClass(); }; } // ── Glass ─────────────────────────────────────────────── function setFilter(node: HTMLElement, filter: GlassFilter) { node.style.setProperty("-webkit-backdrop-filter", filter.full); node.style.setProperty("backdrop-filter", filter.full); // The materialize keyframes in styles.css grow from one to the other. node.style.setProperty("--liquid-glass-full", filter.full); node.style.setProperty("--liquid-glass-clear", filter.clear); } /** The outline of shaped glass as a mask image, for the layers that fill it. */ function outlineMask(path: string, width: number, height: number) { const svg = ``; return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; } /** * Builds glass inside an existing element, which must be the first child of a * positioned, rounded surface. The glass takes the surface's corner radius and sits * behind its content. Layers the element already holds (`[data-layer]`) are kept, * so server-rendered markup hydrates without a flash; missing ones are created. */ export function attachLiquidGlass(element: HTMLElement, initial: LiquidGlassOptions = {}): LiquidGlass { const surface = element.parentElement; if (!surface) throw new Error("Liquid glass needs a surface: attach it to a child of the rounded element."); let options = initial; element.classList.add("liquid-glass"); element.setAttribute("aria-hidden", "true"); const layers = {} as Record; let previous: HTMLElement | null = null; for (const name of LAYERS) { let node = element.querySelector(`:scope > [data-layer="${name}"]`); if (!node) { node = document.createElement("span"); node.className = "liquid-glass-layer"; node.dataset.layer = name; if (previous) previous.after(node); else element.prepend(node); } layers[name] = node; previous = node; } let hueLayer: HTMLElement | null = null; // Interactive glass's pointer light; see lightUp. let light: { element: HTMLElement; remove: () => void } | null = null; const filterId = uniqueId("liquid-glass-refraction"); let refraction: ReturnType | null = null; // True once refraction maps are on screen; the refract layer names the filter from then on. let refracting = false; let appearance: GlassAppearance = "dark"; let animations: Animation[] = []; let animating = 0; // The outline mask of shaped glass, for the layers that fill it. let outline = ""; const paint = () => { const variant = options.variant ?? "regular"; const floating = Boolean(options.floating); appearance = resolveAppearance(options.appearance); const tone = resolveTone(variant, appearance, options.tone, options.level); const filters = toneFilters(tone, floating); const tint = options.tint ?? DEFAULT_TINT[appearance]; const wash = toneWash(tone, tint, floating); setFilter(layers.body, filters.body); layers.body.style.backgroundColor = wash; layers.body.style.setProperty("--liquid-glass-wash", wash); // The refraction bends the body's glass, as seen through its own backdrop. layers.refract.style.setProperty("backdrop-filter", refracting ? `url(#${filterId})` : "none"); setFilter(layers.shade, filters.shade); setFilter(layers.glow, filters.glow); setFilter(layers.edge, filters.edge); setFilter(layers.highlight, filters.highlight); element.dataset.appearance = appearance; element.dataset.variant = variant; element.dataset.fit = options.fit ?? "box"; surface.dataset.liquidGlass = appearance; if (options.floating) surface.setAttribute("data-liquid-glass-floating", options.floating === "dialog" ? "dialog" : ""); else surface.removeAttribute("data-liquid-glass-floating"); surface.toggleAttribute("data-liquid-glass-interactive", Boolean(options.interactive)); // The solid fill that replaces the glass under prefers-reduced-transparency. surface.style.setProperty("--liquid-glass-solid", tint); // For rules that follow the level, like vertical tabs' wells. surface.style.setProperty("--liquid-glass-level", String(Math.min(Math.max(options.level ?? 0.5, 0), 1))); const rgb = options.hue ? parseColour(options.hue) : null; const matrix = rgb ? hueMatrix(rgb, options.hueStrength ?? DEFAULT_HUE_STRENGTH[appearance]) : null; if (matrix) { if (!hueLayer) { hueLayer = document.createElement("span"); hueLayer.className = "liquid-glass-hue"; // Under the pointer light, which is added over everything else. if (light) light.element.before(hueLayer); else element.append(hueLayer); if (outline) setMask(hueLayer, outline, "0 0", "100% 100%"); } const hue = `url(#${hueFilterId(matrix)})`; hueLayer.style.setProperty("-webkit-backdrop-filter", hue); hueLayer.style.setProperty("backdrop-filter", hue); } else { hueLayer?.remove(); hueLayer = null; } }; // ── Lens ── let pixelRatio = slicePixelRatio(); // `requested` is the radius being built; `radius` is the one on screen, which // trails it until the new slices have decoded. `shape` is the box's own radius: // the slices stretch to it, so while the radius animates between rebuilds the rim // keeps the box's shape, a little wider or narrower, instead of cutting across // its corners. let requested = 0; let radius = 0; let shape = 0; let masked = 0; let masks: BuiltGlass["masks"] | null = null; let magnification = 1; let generation = 0; let box = ""; let lastBuild = -Infinity; let timer = 0; let zoomTimer = 0; let disposed = false; // Shaped glass: the build on screen, and the key of the one asked for. let shaped: BuiltShape | null = null; let shapeKey = ""; let shapeTimer = 0; const geometry = () => options.geometry ?? {}; // A magnifying lens bends its whole body, so its refraction is unmasked. const magnifying = () => magnification > 1.0005; const build = (target: number) => { lastBuild = performance.now(); requested = target; const built = buildGlass(target, pixelRatio, geometry()); const current = ++generation; // The current slices stay up until the new ones can be shown masked. void built.decoded.then(() => { if (!disposed && current === generation) apply(built); }); }; /** Lays the masks' slices over the box for a corner radius; `images` also swaps them in. */ const placeMasks = (corner: number, images: boolean) => { if (!masks || shaped) return; masked = corner; for (const mask of MASKS) { // Unmasked, a layer would filter the whole box. if (images) layers[mask].hidden = masks[mask].length === 0; if (layers[mask].hidden) continue; if (mask === "refract" && magnifying()) { if (images) clearMask(layers.refract); continue; } const styles = maskStyles(masks[mask], corner); for (const prefix of ["-webkit-", ""]) { if (images) layers[mask].style.setProperty(`${prefix}mask-image`, styles.image); layers[mask].style.setProperty(`${prefix}mask-position`, styles.position); layers[mask].style.setProperty(`${prefix}mask-size`, styles.size); if (images) layers[mask].style.setProperty(`${prefix}mask-repeat`, "no-repeat"); } } }; const startRefracting = () => { element.dataset.refraction = ""; if (!refracting) { refracting = true; paint(); } }; const apply = (built: BuiltGlass) => { radius = built.slices.radius; masks = built.masks; placeMasks(shape || radius, true); if (refraction) { refraction.setMaps(built.refraction, built.slices.scale); if (element.offsetWidth && element.offsetHeight) { refraction.fit(element.offsetWidth, element.offsetHeight, shape || radius); } startRefracting(); } element.dataset.ready = ""; }; /** Masks every layer of shaped glass with its build, stretched over the box. */ const applyShape = (built: BuiltShape) => { shaped = built; for (const mask of MASKS) { layers[mask].hidden = !built.masks[mask]; if (layers[mask].hidden) continue; if (mask === "refract" && magnifying()) clearMask(layers.refract); else setMask(layers[mask], `url("${built.masks[mask]}")`, "0 0", "100% 100%"); } if (refraction) { refraction.setMaps([built.refraction], built.glass.scale); refraction.fit(element.offsetWidth, element.offsetHeight, 0); startRefracting(); } element.dataset.ready = ""; }; const remask = () => { if (shaped) applyShape(shaped); else placeMasks(masked || shape || radius, true); }; /** Shaped glass: the outline at once, the rim once it is built for this size. */ const layoutShape = (path: string, width: number, height: number) => { const mask = outlineMask(path, width, height); if (mask !== outline) { outline = mask; for (const node of [layers.body, hueLayer, light?.element]) if (node) setMask(node, outline, "0 0", "100% 100%"); } refraction?.fit(width, height, 0); const corner = parseFloat(getComputedStyle(element).borderTopLeftRadius) || Math.min(width, height) / 4; const key = `${path}:${width}x${height}:${buildKey(Math.floor(corner), pixelRatio, geometry())}`; if (key === shapeKey) return; shapeKey = key; window.clearTimeout(shapeTimer); const cached = shapedBuilds.get(key); if (cached) { void cached.decoded.then(() => { if (!disposed && key === shapeKey) applyShape(cached); }); return; } // A build takes a few hundred milliseconds for a large surface: one per size, // once the size holds still and the page is idle. shapeTimer = window.setTimeout(() => { idle(() => { if (disposed || key !== shapeKey) return; const built = buildShape(path, width, height, Math.floor(corner), pixelRatio, geometry()); void built.decoded.then(() => { if (!disposed && key === shapeKey) applyShape(built); }); }, 500); }, REBUILD_INTERVAL); }; const shapePath = (width: number, height: number) => { const option = options.shape; return typeof option === "function" ? option(width, height) : option; }; const layout = (force = false) => { window.clearTimeout(timer); timer = 0; const width = element.offsetWidth; const height = element.offsetHeight; if (!width || !height) return; // Resize callbacks also arrive for unchanged boxes; skip the style read then. const next = `${width}:${height}`; if (!force && next === box) return; box = next; const path = shapePath(width, height); if (path) return layoutShape(path, width, height); const exact = Math.min(parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0, width / 2, height / 2); const target = Math.floor(exact); if (target >= 1) shape = exact; if (target >= 1 && target !== requested) { const wait = lastBuild + REBUILD_INTERVAL - performance.now(); // A radius built before costs nothing to swap to, so it isn't held back. if (wait <= 0 || builds.has(buildKey(target, pixelRatio, geometry()))) build(target); // Keep the current slices while the radius animates, then catch up. else timer = window.setTimeout(() => layout(true), wait); } if (!radius) return; if (Math.abs(shape - masked) > 0.01) placeMasks(shape, false); refraction?.fit(width, height, shape); }; const onTransitionEnd = (event: TransitionEvent) => { if (event.target === surface && event.propertyName.includes("radius")) layout(true); }; /** Forgets the lens on screen so the next layout builds it afresh. */ const relayout = () => { requested = 0; shapeKey = ""; // Into a shape: the rim waits for its build rather than show the slices stretched over it. if (options.shape && !shaped) delete element.dataset.ready; if (!options.shape && (shaped || outline)) { // Back to a rounded box: the outline comes off the layers that filled it. shaped = null; outline = ""; for (const node of [layers.body, hueLayer, light?.element]) if (node) clearMask(node); for (const mask of MASKS) clearMask(layers[mask]); masks = null; radius = 0; delete element.dataset.ready; } layout(true); }; let samples: 1 | 4 = 4; const createRefraction = () => { const created = createRefractionFilter(filterId, samples); created.magnify(magnification); return created; }; const setRefraction = (enabled: boolean, nextSamples: 1 | 4) => { const resample = nextSamples !== samples; samples = nextSamples; if (enabled === Boolean(refraction) && !(enabled && resample)) return; refraction?.remove(); refraction = null; if (enabled) { // The refract layer keeps naming the filter id, which the new filter takes over. refraction = createRefraction(); relayout(); return; } refracting = false; delete element.dataset.refraction; paint(); }; // Browser zoom changes the pixel ratio; rebuild the slices for it once the zoom // settles. The current slices stay up meanwhile. let resolution: MediaQueryList | null = null; const watchResolution = () => { resolution?.removeEventListener("change", onResolutionChange); resolution = window.matchMedia(`(resolution: ${devicePixelRatio}dppx)`); resolution.addEventListener("change", onResolutionChange); }; function onResolutionChange() { watchResolution(); window.clearTimeout(zoomTimer); zoomTimer = window.setTimeout(() => { const next = slicePixelRatio(); if (next === pixelRatio) return; pixelRatio = next; relayout(); }, ZOOM_SETTLE); } // ── Fit ── // A surface that scrolls itself: the glass spans its scroll height. Content growing // inside the surface resizes its children, not the surface, so they are watched too. let scrollFit: (() => void) | null = null; const spanScroll = () => { const fit = () => { // The glass is part of the scrollable overflow, so measure without it or the // surface could never shrink back. element.style.height = "0px"; element.style.height = `${surface.scrollHeight}px`; }; const resize = new ResizeObserver(fit); const watch = () => { resize.disconnect(); resize.observe(surface); for (const child of surface.children) { if (child !== element) resize.observe(child); } }; const children = new MutationObserver(() => { watch(); fit(); }); watch(); fit(); children.observe(surface, { childList: true }); return () => { resize.disconnect(); children.disconnect(); element.style.removeProperty("height"); }; }; const setFit = (fit: LiquidGlassFit) => { if ((fit === "scroll") === Boolean(scrollFit)) return; if (scrollFit) { scrollFit(); scrollFit = null; } else { scrollFit = spanScroll(); } }; // ── Light ── // Interactive glass lights up from within: light added over the glass layers, // under the content, since a brightness filter can't lift dark glass, which is // near black. It follows the pointer through its own properties: set on the // surface they would be inherited, restyling the surface's content on every move. const lightUp = () => { const node = document.createElement("span"); node.className = "liquid-glass-light"; element.append(node); if (outline) setMask(node, outline, "0 0", "100% 100%"); const place = (event: PointerEvent) => { const frame = element.getBoundingClientRect(); node.style.setProperty("--liquid-glass-x", `${event.clientX - frame.left}px`); node.style.setProperty("--liquid-glass-y", `${event.clientY - frame.top}px`); }; const onEnter = (event: PointerEvent) => { if (event.pointerType !== "touch") surface.toggleAttribute("data-liquid-glass-pointer", true); }; const onLeave = () => surface.removeAttribute("data-liquid-glass-pointer"); // Touch only moves the light while pressed; a mouse moves it all the time. const onMove = (event: PointerEvent) => { if (event.pointerType !== "touch" || surface.hasAttribute("data-liquid-glass-pressed")) place(event); }; const onRelease = () => { window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); surface.removeAttribute("data-liquid-glass-pressed"); }; const onDown = (event: PointerEvent) => { if (event.button !== 0) return; place(event); surface.toggleAttribute("data-liquid-glass-pressed", true); window.addEventListener("pointerup", onRelease); window.addEventListener("pointercancel", onRelease); }; surface.addEventListener("pointerenter", onEnter); surface.addEventListener("pointerleave", onLeave); surface.addEventListener("pointermove", onMove); surface.addEventListener("pointerdown", onDown); return { element: node, remove() { surface.removeEventListener("pointerenter", onEnter); surface.removeEventListener("pointerleave", onLeave); surface.removeEventListener("pointermove", onMove); surface.removeEventListener("pointerdown", onDown); onRelease(); onLeave(); node.remove(); }, }; }; const setInteractive = (enabled: boolean) => { if (enabled === Boolean(light)) return; if (enabled) light = lightUp(); else { light?.remove(); light = null; } }; // ── Swell ── let swelling: (() => void) | null = null; const setSwell = (enabled: boolean) => { if (enabled === Boolean(swelling)) return; if (enabled) swelling = swellOnPress(surface); else { swelling?.(); swelling = null; } }; // ── Pouring ── let morph: ReturnType | null = null; // Repaint when the appearance this surface follows flips, and only then. const onAppearanceSignal = () => { if (resolveAppearance(options.appearance) !== appearance) paint(); }; const animate = (direction: "in" | "out", timing: GlassTiming = {}) => { for (const animation of animations) animation.cancel(); const run = ++animating; // Glass eases in and out evenly (ease-out cubic): on a quint curve nearly half // of an opening landed in its first frame at 60 fps, and most of a closing in // its first two, so it seemed to appear and vanish rather than come and go. const effect = { duration: timing.duration ?? (direction === "in" ? 240 : 180), easing: timing.easing ?? "cubic-bezier(0.33, 1, 0.68, 1)", delay: timing.delay ?? 0, fill: direction === "in" ? "backwards" : "forwards", } satisfies KeyframeAnimationOptions; // The refraction holds still while the rest materializes; it goes once the glass has. if (direction === "in") layers.refract.style.removeProperty("visibility"); const nodes = LAYERS.filter(name => name !== "refract").map(name => layers[name]); animations = nodes.map(node => { const full = node.style.getPropertyValue("--liquid-glass-full"); const clear = node.style.getPropertyValue("--liquid-glass-clear"); const wash = node.style.getPropertyValue("--liquid-glass-wash") || "transparent"; const from = { backdropFilter: clear, webkitBackdropFilter: clear, backgroundColor: "transparent" }; const to = { backdropFilter: full, webkitBackdropFilter: full, backgroundColor: wash }; return node.animate(direction === "in" ? [from, to] : [to, from], effect); }); if (timing.scale !== undefined && timing.scale !== 1) { const inset = `${((1 - timing.scale) * 50).toFixed(3)}%`; const keyframes = [{ inset }, { inset: "0px" }]; animations.push(element.animate(direction === "in" ? keyframes : keyframes.reverse(), effect)); } // Floating glass's shadow grows in and out with it; left as it was, it hung // around a cleared surface as a dark outline until the surface went. const shadow = options.floating ? getComputedStyle(surface).boxShadow : "none"; if (shadow && shadow !== "none") { const keyframes = [{ boxShadow: "none" }, { boxShadow: shadow }]; animations.push(surface.animate(direction === "in" ? keyframes : keyframes.reverse(), effect)); } // A well on the glass can't fade with the surface's contents either, so it // eases back to clear glass with the rest (styles.css). surface.toggleAttribute("data-liquid-glass-leaving", direction === "out"); return Promise.all(animations.map(animation => animation.finished.catch(() => undefined))).then(() => { if (direction === "out" && run === animating && !disposed) layers.refract.style.visibility = "hidden"; }); }; paint(); setFit(options.fit ?? "box"); setInteractive(Boolean(options.interactive)); setSwell(options.swell ?? Boolean(options.interactive)); samples = options.samples === 1 ? 1 : 4; if (options.refraction !== false && supportsRefraction()) refraction = createRefraction(); const observer = new ResizeObserver(() => layout()); observer.observe(element); surface.addEventListener("transitionend", onTransitionEnd); const unwatchAppearance = watchAppearance(onAppearanceSignal); watchResolution(); // Menus and popovers pour out of their buttons through corners from 4 to 48 px. if (options.floating && !options.shape) prewarmMorph(geometry()); return { element, get appearance() { return appearance; }, update(next) { const rebuild = JSON.stringify(next.geometry ?? {}) !== JSON.stringify(options.geometry ?? {}) || Boolean(next.shape) !== Boolean(options.shape) || (typeof next.shape === "string" && next.shape !== options.shape); options = next; paint(); setFit(next.fit ?? "box"); setInteractive(Boolean(next.interactive)); setSwell(next.swell ?? Boolean(next.interactive)); setRefraction(next.refraction !== false && supportsRefraction(), next.samples === 1 ? 1 : 4); if (rebuild) relayout(); }, magnify(factor) { const was = magnifying(); magnification = factor; refraction?.magnify(factor); if (magnifying() !== was) remask(); }, materialize: timing => animate("in", timing), dematerialize: timing => animate("out", timing), pourFrom(source, pour) { if (!options.shape) prewarmMorph(geometry()); // It is glass the whole way, as iOS 27's is: every layer stays at full // strength while it pours out and draws back, instead of materializing. for (const animation of animations) animation.cancel(); animations = []; animating++; surface.removeAttribute("data-liquid-glass-leaving"); layers.refract.style.removeProperty("visibility"); morph ??= createGlassMorph(element, surface); return morph.pour(source, pour); }, drawBack() { return morph ? morph.drawBack() : Promise.resolve(true); }, destroy() { disposed = true; window.clearTimeout(timer); window.clearTimeout(shapeTimer); window.clearTimeout(zoomTimer); observer.disconnect(); surface.removeEventListener("transitionend", onTransitionEnd); unwatchAppearance(); resolution?.removeEventListener("change", onResolutionChange); for (const animation of animations) animation.cancel(); morph?.destroy(); morph = null; scrollFit?.(); scrollFit = null; setInteractive(false); setSwell(false); refraction?.remove(); hueLayer?.remove(); delete element.dataset.ready; delete element.dataset.refraction; delete surface.dataset.liquidGlass; surface.removeAttribute("data-liquid-glass-floating"); surface.removeAttribute("data-liquid-glass-interactive"); surface.removeAttribute("data-liquid-glass-leaving"); surface.style.removeProperty("--liquid-glass-solid"); surface.style.removeProperty("--liquid-glass-level"); }, }; } /** * Turns any positioned, rounded element into a glass surface. The glass is inserted * as its first child and removed again by `destroy()`. * * ```ts * const glass = createLiquidGlass(document.querySelector(".toolbar")!); * ``` */ export function createLiquidGlass(surface: HTMLElement, options: LiquidGlassOptions = {}): LiquidGlass { const element = document.createElement("span"); surface.prepend(element); const glass = attachLiquidGlass(element, options); return { element, get appearance() { return glass.appearance; }, update: glass.update, magnify: glass.magnify, materialize: glass.materialize, dematerialize: glass.dematerialize, pourFrom: glass.pourFrom, drawBack: glass.drawBack, destroy() { glass.destroy(); element.remove(); }, }; }