import { createGlassSlices, DEFAULT_GEOMETRY, MASKS, SLICES, sliceRects, type GlassGeometry, type GlassMask, type GlassSlices, } from "./lens"; import { LENS_PT, spring, STEP, stepSpring, SWELL, SWELL_PT } from "./spring"; import { DEFAULT_HUE_STRENGTH, DEFAULT_TINT, hueMatrix, resolveTone, toneFilters, toneWash, type GlassAppearance, type GlassFilter, type GlassToneOverrides, type GlassVariant, } from "./tone"; /* Liquid Glass after iOS 27, without a frosted fill. Every layer is the page behind the glass, filtered: a body that diffuses it, 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", "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; /** Per-appearance overrides of the variant's tone. */ tone?: GlassToneOverrides | undefined; geometry?: Partial | 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: 1, or 4 to antialias the bend. 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, while flat * glass, which shifts nothing, stays sharp. They cost four displacement passes, * so they suit small glass with nothing blurring its backdrop, like a lens. */ 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, and a press blooms out from there. */ interactive?: boolean | undefined; /** * Pressed, the surface swells about its centre and springs back with a slight * overshoot on release, as the tabs' bar does, by the same points, so a field and * a tab bar feel like one material. 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; destroy(): void; }; // ── Slices ────────────────────────────────────────────── 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 decodeSlices. */ 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. Slices are decoded before use and the images // kept for as long as the build is cached, so masks and feImages that name these // URLs find them ready. const decodedImages = new WeakMap(); function decodeSlices(built: Omit) { const images = [...MASKS.flatMap(mask => built.masks[mask]), ...built.refraction].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 32 slices mid-animation. const builds = new Map(); const BUILD_CACHE_SIZE = 12; function buildGlass(radius: number, pixelRatio: number, geometry: Partial) { const key = `${radius}:${pixelRatio}:${JSON.stringify(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) : [], }, refraction: sliceUrls(slices.refraction, slices), }; const { images, decoded } = decodeSlices(urls); 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; } /** 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}`, }; } // ── 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 canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; canvas.getContext("2d")?.putImageData(new ImageData(pixels, size, size), 0, 0); const url = canvas.toDataURL(); const image = new Image(); image.src = url; magnificationField = { url, image, decoded: image.decode().catch(() => {}) }; return magnificationField; } /** * An SVG filter that assembles refraction slices 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 { setSlices(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. */ 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 = [ [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], ]; used = rects.map(([, , w = 0, h = 0]) => w > 0 && h > 0); covered = 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(); }; } // ── Swell ─────────────────────────────────────────────── /** * The tabs' press, for any surface: pressed, it swells about its centre on the * tabs' bouncy spring and springs back with a slight overshoot on release. * Returns a cleanup function. */ function swellOnPress(surface: HTMLElement) { const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const swell = spring(); let target = 0; let frame = 0; let last = 0; const write = () => { const width = surface.offsetWidth; const height = surface.offsetHeight; const pt = Math.min(width, height) / LENS_PT || 1; const length = Math.max(width, height); surface.style.scale = Math.abs(swell.value) > 1e-4 ? (1 + (swell.value * SWELL_PT * pt) / length).toFixed(5) : ""; }; const tick = (now: number) => { // Fixed steps, as the tabs take them, so the bounce is the same at any frame rate. const elapsed = Math.min(Math.max(now - last, 0) / 1000, 1 / 20); last = now; for (let t = 0; t < elapsed; t += STEP) stepSpring(swell, target, SWELL, STEP); if (target === 0 && Math.abs(swell.value) < 0.0005 && Math.abs(swell.velocity) < 0.01) { swell.value = 0; swell.velocity = 0; surface.style.scale = ""; frame = 0; return; } write(); frame = requestAnimationFrame(tick); }; const run = () => { if (frame) return; last = performance.now(); frame = requestAnimationFrame(tick); }; const onRelease = () => { window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); target = 0; run(); }; const onDown = (event: PointerEvent) => { if (event.button !== 0 || reduced.matches) return; target = 1; window.addEventListener("pointerup", onRelease); window.addEventListener("pointercancel", onRelease); run(); }; surface.addEventListener("pointerdown", onDown); return () => { surface.removeEventListener("pointerdown", onDown); window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); if (frame) cancelAnimationFrame(frame); surface.style.scale = ""; }; } // ── Glass ─────────────────────────────────────────────── function setFilter(node: HTMLElement, filter: GlassFilter, refraction?: string) { node.style.setProperty("-webkit-backdrop-filter", filter.full); node.style.setProperty( "backdrop-filter", refraction ? `${filter.full === "none" ? "" : `${filter.full} `}${refraction}` : 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); } /** * 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 slices are on screen; the body names the filter from then on. let refracting = false; let appearance: GlassAppearance = "dark"; let animations: Animation[] = []; const paint = () => { const variant = options.variant ?? "regular"; appearance = resolveAppearance(options.appearance); const tone = resolveTone(variant, appearance, options.tone); const filters = toneFilters(tone, Boolean(options.floating)); const tint = options.tint ?? DEFAULT_TINT[appearance]; const wash = toneWash(tone, tint); setFilter(layers.body, filters.body, refracting ? `url(#${filterId})` : undefined); layers.body.style.backgroundColor = wash; layers.body.style.setProperty("--liquid-glass-wash", wash); 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"); // The solid fill that replaces the glass under prefers-reduced-transparency. surface.style.setProperty("--liquid-glass-solid", tint); 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); } const hue = `url(#${hueFilterId(matrix)})`; hueLayer.style.setProperty("-webkit-backdrop-filter", hue); hueLayer.style.setProperty("backdrop-filter", hue); } else { hueLayer?.remove(); hueLayer = null; } }; // ── Lens ── const currentPixelRatio = () => Math.min(Math.ceil(devicePixelRatio), 2); let pixelRatio = currentPixelRatio(); // `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; const build = (target: number) => { lastBuild = performance.now(); requested = target; const built = buildGlass(target, pixelRatio, options.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) 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; 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 apply = (built: BuiltGlass) => { radius = built.slices.radius; masks = built.masks; placeMasks(shape || radius, true); if (refraction) { refraction.setSlices(built.refraction, built.slices.scale); if (element.offsetWidth && element.offsetHeight) { refraction.fit(element.offsetWidth, element.offsetHeight, shape || radius); } element.dataset.refraction = ""; if (!refracting) { refracting = true; paint(); } } element.dataset.ready = ""; }; 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 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(); if (wait <= 0) 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); }; let samples: 1 | 4 = 1; 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 body keeps naming the filter id, which the new filter takes over. refraction = createRefraction(); requested = 0; layout(true); 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 = currentPixelRatio(); if (next === pixelRatio) return; pixelRatio = next; requested = 0; layout(true); }, 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); 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; } }; // 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 effect = { duration: timing.duration ?? 240, easing: timing.easing ?? "cubic-bezier(0.22, 1, 0.36, 1)", delay: timing.delay ?? 0, fill: direction === "in" ? "backwards" : "forwards", } satisfies KeyframeAnimationOptions; const nodes = LAYERS.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)); } // 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(() => {}); }; paint(); setFit(options.fit ?? "box"); setInteractive(Boolean(options.interactive)); setSwell(Boolean(options.swell)); samples = options.samples === 4 ? 4 : 1; if (options.refraction !== false && supportsRefraction()) refraction = createRefraction(); const observer = new ResizeObserver(() => layout()); observer.observe(element); surface.addEventListener("transitionend", onTransitionEnd); const unwatchAppearance = watchAppearance(onAppearanceSignal); watchResolution(); return { element, get appearance() { return appearance; }, update(next) { const geometryChanged = JSON.stringify(next.geometry ?? {}) !== JSON.stringify(options.geometry ?? {}); options = next; paint(); setFit(next.fit ?? "box"); setInteractive(Boolean(next.interactive)); setSwell(Boolean(next.swell)); setRefraction(next.refraction !== false && supportsRefraction(), next.samples === 4 ? 4 : 1); if (geometryChanged) { requested = 0; layout(true); } }, magnify(factor) { magnification = factor; refraction?.magnify(factor); }, materialize: timing => animate("in", timing), dematerialize: timing => animate("out", timing), destroy() { disposed = true; window.clearTimeout(timer); window.clearTimeout(zoomTimer); observer.disconnect(); surface.removeEventListener("transitionend", onTransitionEnd); unwatchAppearance(); resolution?.removeEventListener("change", onResolutionChange); for (const animation of animations) animation.cancel(); 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-leaving"); surface.style.removeProperty("--liquid-glass-solid"); }, }; } /** * 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, destroy() { glass.destroy(); element.remove(); }, }; }