import { kickGlass } from "./swell.js"; import { STEP, stepSpring, type Spring, type SpringConfig } from "./spring.js"; /* * Glass menus and popovers pour out of the button that opened them and draw back * into it, as iOS 27's do. Every number here was measured frame by frame from iOS * 27's own menus (SwiftUI toolbar and iPad toolbar menus, and apps' menus). * * Opening, the button itself becomes the drop: the glass starts as the button, the * button fades, and the drop inflates into the menu over it, its corner where the * button was: its height leads and overshoots by 7%, its width follows and * overshoots by 2.5%, and it settles in about 450 ms. Closing, the contents go * first and the glass rounds into a drop that shrinks back into the button, which * reappears with a bulge, in about 200 ms. A sub-menu grows the same way out of * its row, beside the menu. * * What flies is the surface's own glass, so it is the same glass on every frame as * at rest. Only its box and corners move (the glass breaks if it scales), shaped by * border-radius alone, as at rest, so every GPU draws it the same. */ /** A side of the source the surface sits on. */ export type GlassSide = "top" | "right" | "bottom" | "left"; /** What glass pours out of: the button or field that opened it, or a point, for a context menu. */ export type GlassPourSource = Element | { x: number; y: number }; export type GlassPourOptions = { /** * Move the surface over the button that opened it, so the button becomes the * menu, as iOS 27's menus do: the surface moves back along its side of the button * until it covers it. For buttons up to 56px tall; a taller source (a card, a * row) keeps its surface beside it. Leave it off for popovers anchored to a field * they mustn't hide. */ cover?: boolean | undefined; /** Which side of the source the surface sits on. Measured when left out. */ side?: GlassSide | undefined; /** * A sub-menu, which unfolds out of its own edge beside the row it came from, in * about 200 ms, and folds back in about 120, so the menu being hovered stays in * view. */ submenu?: boolean | undefined; }; type Box = { left: number; top: number; right: number; bottom: number; radius: number }; type Axis = { spring: SpringConfig; launch: number }; type Motion = { width: Axis; height: Axis; anchor: Axis; hold: number }; // Opening, fitted to the SwiftUI toolbar menu's size every frame, launched a little // softer than it is, so the drop leaves the button small and inflates over five // frames rather than two. const OPEN: Motion = { width: { spring: { omega: 15, zeta: 0.79 }, launch: 7 }, height: { spring: { omega: 21, zeta: 0.71 }, launch: 13 }, anchor: { spring: { omega: 22, zeta: 0.86 }, launch: 4 }, hold: 0, }; // Closing: the contents go in a frame, then the glass rounds into a drop that // narrows faster than it shortens and rises into the button, taken back while // still moving, by about 200 ms. const CLOSE: Motion = { width: { spring: { omega: 21, zeta: 0.9 }, launch: 3 }, height: { spring: { omega: 18, zeta: 0.9 }, launch: 2.5 }, anchor: { spring: { omega: 23, zeta: 0.95 }, launch: 2 }, hold: 0.03, }; // A drop still drawing back when another surface starts pouring out hurries home // on these, so the two don't hang over each other. const HURRY: Motion = { width: { spring: { omega: 30, zeta: 0.9 }, launch: 0 }, height: { spring: { omega: 26, zeta: 0.9 }, launch: 0 }, anchor: { spring: { omega: 32, zeta: 0.95 }, launch: 0 }, hold: 0, }; const SUB_OPEN: Motion = { width: { spring: { omega: 26, zeta: 0.82 }, launch: 6 }, height: { spring: { omega: 28, zeta: 0.78 }, launch: 8 }, anchor: { spring: { omega: 30, zeta: 0.9 }, launch: 0 }, hold: 0, }; const SUB_CLOSE: Motion = { width: { spring: { omega: 34, zeta: 0.95 }, launch: 2 }, height: { spring: { omega: 32, zeta: 0.95 }, launch: 2 }, anchor: { spring: { omega: 34, zeta: 1 }, launch: 0 }, hold: 0, }; // The drop a sub-menu unfolds from: this share of its row's height, round, at its // own edge. Folding back, the edge takes it once it is this many times that drop, // so no dot lingers there as the spring slows. const SUB_DROP = 0.7; const SUB_TAKEN_BACK = 1.3; // Closing, the drop heads for this share of its source's size, centred on it. The // button it covered fades back in as the drop reaches the button's size, and the // drop is taken back once it is this share of the button, still moving: taken at // the button's size, the glass vanished and the button appeared in the same frame. const SINK = 0.12; const SHOW_AT = 1.15; const TAKEN_BACK = 0.3; const BUTTON_RETURN = 110; // A surface covers a button no taller than this. const COVER_MAX = 56; // A second press this soon after releasing the one that opened a menu over its // button is a double-click on the button, and closes the menu. const DOUBLE_PRESS = 250; // The button fades as the drop leaves it. const BUTTON_FADE = 80; /** * The corner radii glass passes through as it pours out, whole pixels from 4 to 48. * Built ahead of time (prewarmLiquidGlass), the rim is drawn for exactly the * corners it has on every frame instead of stretched from another radius. */ export const MORPH_RADII = Array.from({ length: 45 }, (_, index) => 4 + index); const MAX_RADIUS = 48; // The corners follow their own critically damped spring: snapped to a coarser // ladder, or tightened to the surface's in a few frames as it closed, they jumped // up to 27 px in a frame. const RADIUS: SpringConfig = { omega: 45, zeta: 1 }; // The softest a source's corners start, so square buttons still pour out as liquid. const MIN_SOURCE_RADIUS = 8; // A context menu blooms from a drop this wide under the pointer. const POINTER_DROP = 24; // The contents blur by this much while the glass is small... const CONTENT_BLUR = 5; // ...and, closing, are gone in this many seconds. const CONTENT_OUT = 0.06; // A button swells as the drop comes back into it, and the glass it sits in bulges. const SOURCE_SWELL = 1.06; const SOURCE_EASE = "cubic-bezier(0.3, 0.7, 0.4, 1)"; const ABSORB_KICK = 22; /** Sent from a surface once its glass has landed and its contents are back at rest. */ export const GLASS_LANDED = "liquid-glass-landed"; function smoothstep(from: number, to: number, value: number) { const t = Math.min(1, Math.max(0, (value - from) / (to - from))); return t * t * (3 - 2 * t); } function cornerRadius(element: Element, width: number, height: number) { const radius = parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0; return Math.min(radius, width / 2, height / 2); } function still() { return matchMedia("(prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce), (forced-colors: active)").matches; } // What ends a press, including the window losing focus mid-press. An element losing // focus also reaches a capturing window listener as a blur, and doesn't. const PRESS_ENDS = ["pointerup", "pointercancel", "blur"] as const; const endsPress = (event: Event) => event.type !== "blur" || event.target === window; // Whether a press is held, so a menu that press opened knows its release is still // to come (catcher). Noted from the start, since a menu mounts after the press. let pressHeld = false; if (typeof window !== "undefined") { window.addEventListener("pointerdown", event => { if (event.button === 0) pressHeld = true; }, { capture: true, passive: true }); for (const type of PRESS_ENDS) { window.addEventListener(type, event => { if (endsPress(event)) pressHeld = false; }, { capture: true, passive: true }); } } // The drops drawing back now, each with its way to hurry. const drawingBack = new Set<() => void>(); /** The side of `source` that `surface` sits on, from where they are. */ function sideOf(surface: DOMRect, source: DOMRect): GlassSide { const gaps: [GlassSide, number][] = [ ["bottom", surface.top - source.bottom], ["top", source.top - surface.bottom], ["right", surface.left - source.right], ["left", source.left - surface.right], ]; // The side it is furthest out on; overlapping sources count as below. const [side, gap] = gaps.reduce((best, next) => (next[1] > best[1] ? next : best)); return gap > -4 ? side : "bottom"; } /** * Moves a surface by `translate` at once, even one that transitions its translate: * style is flushed with transitions off, then they come back for what follows. */ function translateInstantly(surface: HTMLElement, translate: string | null) { const transition = surface.style.transitionProperty; surface.style.transitionProperty = "none"; if (translate) surface.style.translate = translate; else surface.style.removeProperty("translate"); surface.getBoundingClientRect(); surface.style.transitionProperty = transition; } /** * Moves a surface, placed beside the button that opened it, over that button: it * moves back along its side of the button until its near edge is on the button's * far one, so the button's corner is the surface's. Returns whether it covers it. */ function coverButton(surface: HTMLElement, button: Element, side: GlassSide) { const box = button.getBoundingClientRect(); if (!box.height || box.height > COVER_MAX) return false; const menu = surface.getBoundingClientRect(); const dx = side === "right" ? box.left - menu.left : side === "left" ? box.right - menu.right : 0; const dy = side === "bottom" ? box.top - menu.top : side === "top" ? box.bottom - menu.bottom : 0; translateInstantly(surface, `${dx.toFixed(2)}px ${dy.toFixed(2)}px`); return true; } /** * Over the button a surface covers, a catcher keeps the press that opened it: menus * choose the item a press is released over, so letting go where the button was * would choose the item that now lies there. It goes shortly after that release, so * the rows beneath it can be hovered and chosen; a second press before then is a * double-click on the button and closes the menu with Escape. A surface opened with * no press held, as from the keyboard, gets none. */ function buttonCatcher(surface: HTMLElement) { let catcher: HTMLElement | null = null; let timer = 0; const listen = (on: boolean) => { for (const type of PRESS_ENDS) { if (on) window.addEventListener(type, release, true); else window.removeEventListener(type, release, true); } }; const remove = () => { listen(false); window.clearTimeout(timer); catcher?.remove(); }; function release(event: Event) { if (!endsPress(event)) return; listen(false); window.clearTimeout(timer); timer = window.setTimeout(remove, DOUBLE_PRESS); } return { place(box: { left: number; top: number; right: number; bottom: number }) { const placed = catcher?.parentElement === surface; if (!placed && !pressHeld) return; if (!catcher) { catcher = document.createElement("span"); catcher.className = "liquid-glass-catcher"; catcher.setAttribute("aria-hidden", "true"); catcher.addEventListener("pointerdown", event => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); surface.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); }); } catcher.style.left = `${box.left}px`; catcher.style.top = `${box.top}px`; catcher.style.width = `${box.right - box.left}px`; catcher.style.height = `${box.bottom - box.top}px`; if (placed) return; window.clearTimeout(timer); surface.append(catcher); listen(true); }, remove, }; } /** Hides a button while the surface it became is out; `show` brings it back, over `fade` ms. */ function buttonHider() { let hidden: { button: Element; animation: Animation } | null = null; return { hide(button: Element, fade: number) { if (hidden) return; hidden = { button, animation: button.animate([{ opacity: 1 }, { opacity: 0 }], { duration: fade, easing: "ease-out", fill: "forwards" }) }; }, show(fade = 0) { if (!hidden) return; const { button, animation } = hidden; hidden = null; animation.cancel(); if (fade > 0) button.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fade, easing: "ease-out" }); }, }; } /** * Pours `glass`, the glass element of `surface`, out of a source and draws it back. * Moves the glass box and the surface's other children itself. */ export function createGlassMorph(glass: HTMLElement, surface: HTMLElement) { const button = buttonHider(); const catcher = buttonCatcher(surface); const width: Spring = { value: 0, velocity: 0 }; const height: Spring = { value: 0, velocity: 0 }; const anchorX: Spring = { value: 0, velocity: 0 }; const anchorY: Spring = { value: 0, velocity: 0 }; const radius: Spring = { value: 0, velocity: 0 }; let motion = OPEN; let target = { width: 0, height: 0, x: 0, y: 0 }; // Everything below is in the surface's coordinates, from the last measure. let menu: Box = { left: 0, top: 0, right: 0, bottom: 0, radius: 0 }; let from: Box = { ...menu }; // Where it heads closing: the middle of its source. let sink: Box = { ...menu }; let anchorShare = { x: 0, y: 0 }; let placements = new Map(); let source: GlassPourSource | null = null; let options: GlassPourOptions = {}; // A point source, in the surface's coordinates when it opened. let pointFrom: { x: number; y: number } | null = null; let covered = false; let closing = false; let flying = false; let elapsed = 0; let absorbed = false; // Closing, whether the covered button has started fading back in. let returning = false; let frame = 0; let last = 0; let carry = 0; let shadow: HTMLElement | null = null; // Callers waiting on the current pour or draw back: true once it finishes, false // if the other one interrupts it. let waiting: ((done: boolean) => void)[] = []; const element = () => (source instanceof Element ? source : null); const standalone = () => !options.submenu; // The hover pill is a backdrop filter, which can't fade; it hides in flight. const wells = () => Array.from(surface.children).filter((child): child is HTMLElement => child instanceof HTMLElement && child.classList.contains("liquid-glass-well")); const contents = () => Array.from(surface.children).filter( (child): child is HTMLElement => child instanceof HTMLElement && child !== glass && !child.classList.contains("liquid-glass-well") && !child.classList.contains("liquid-glass-catcher"), ); const finish = (done: boolean) => { const callers = waiting; waiting = []; for (const resolve of callers) resolve(done); }; const wait = () => new Promise(resolve => waiting.push(resolve)); // Closing, the drop heads for the middle of its source. const sinkInto = (box: Box): Box => { const cx = (box.left + box.right) / 2; const cy = (box.top + box.bottom) / 2; const sw = (box.right - box.left) * SINK; const sh = (box.bottom - box.top) * SINK; return { left: cx - sw / 2, top: cy - sh / 2, right: cx + sw / 2, bottom: cy + sh / 2, radius: Math.min(sw, sh) / 2 }; }; // Reads the source and the surface. Every layout read happens here, so frames only write. const measure = () => { const bounds = surface.getBoundingClientRect(); menu = { left: 0, top: 0, right: bounds.width, bottom: bounds.height, radius: cornerRadius(surface, bounds.width, bounds.height) }; const trigger = element(); let side: GlassSide | null = null; if (!trigger) { // A context menu: the drop starts at the point, or where it opened, closing. const point = source && !(source instanceof Element) ? source : { x: bounds.left, y: bounds.top }; const x = closing && pointFrom ? pointFrom.x : Math.min(Math.max(point.x - bounds.left, 0), bounds.width); const y = closing && pointFrom ? pointFrom.y : Math.min(Math.max(point.y - bounds.top, 0), bounds.height); pointFrom = { x, y }; const half = POINTER_DROP / 2; from = { left: x - half, top: y - half, right: x + half, bottom: y + half, radius: half }; const small = half * SINK; sink = { left: x - small, top: y - small, right: x + small, bottom: y + small, radius: small }; } else { const box = trigger.getBoundingClientRect(); if (!box.width || !box.height) return false; side = options.side ?? sideOf(bounds, box); const corner = Math.max(cornerRadius(trigger, box.width, box.height), Math.min(MIN_SOURCE_RADIUS, box.width / 2, box.height / 2)); // The glass starts as the whole of what was clicked: the button becomes the // drop. A sub-menu instead unfolds from a drop on its own near edge, level // with its row: grown from the row itself, it slid across the menu as a // slab, hiding the rows the pointer was moving to. from = { left: box.left - bounds.left, top: box.top - bounds.top, right: box.right - bounds.left, bottom: box.bottom - bounds.top, radius: corner }; if (!standalone() && (side === "left" || side === "right")) { const size = (from.bottom - from.top) * SUB_DROP; const cy = (from.top + from.bottom) / 2; const edge = side === "right" ? menu.left : menu.right; const left = side === "right" ? edge : edge - size; from = { left, right: left + size, top: cy - size / 2, bottom: cy + size / 2, radius: size / 2 }; } sink = sinkInto(from); } // The drop grows from the surface's corner or edge nearest the source. const share = (value: number, start: number, size: number) => Math.min(1, Math.max(0, (value - start) / size)); anchorShare = { x: side === "left" ? 1 : side === "right" ? 0 : share((from.left + from.right) / 2, menu.left, menu.right - menu.left), y: side === "top" ? 1 : side === "bottom" ? 0 : share((from.top + from.bottom) / 2, menu.top, menu.bottom - menu.top), }; placements = new Map(contents().map(child => [child, { x: child.offsetLeft - surface.scrollLeft, y: child.offsetTop - surface.scrollTop }])); return true; }; // The corners the drop wants at a size: round while it flies, tightening to the // surface's as it lands. Closing, it rounds into a drop soon after it starts to // shrink, rather than shrinking as a slab of the menu. const wantedRadius = (w: number, h: number) => { const fullW = menu.right - menu.left; const fullH = menu.bottom - menu.top; const landed = smoothstep(closing ? 0.85 : 0.75, 1, Math.min(w / fullW, h / fullH)); const round = Math.min(Math.min(w, h) / 2, Math.max(MAX_RADIUS, menu.radius)); return Math.max(0, Math.min(round + (menu.radius - round) * landed, Math.min(w, h) / 2)); }; // Whole pixels, as the glass's refraction is fitted to them, and so are the // corners, which its rim is built for. const rect = (): Box => { const w = Math.max(1, Math.round(width.value)); const h = Math.max(1, Math.round(height.value)); const left = Math.round(anchorX.value - anchorShare.x * w); const top = Math.round(anchorY.value - anchorShare.y * h); const corner = Math.round(Math.min(Math.max(radius.value, 0), Math.min(w, h) / 2)); return { left, top, right: left + w, bottom: top + h, radius: corner }; }; const pulse = () => { element()?.animate([{ scale: "1" }, { scale: String(SOURCE_SWELL), offset: 0.35 }, { scale: "1" }], { duration: 420, easing: SOURCE_EASE }); }; const paint = () => { const drop = rect(); const fullW = menu.right - menu.left; const fullH = menu.bottom - menu.top; const w = drop.right - drop.left; const h = drop.bottom - drop.top; const grown = Math.max(0, Math.min(w / fullW, h / fullH)); // The glass box is the drop: moved and sized through its inset, rounded by its // corners, which every glass layer inherits. glass.style.inset = `${drop.top}px ${fullW - drop.right}px ${fullH - drop.bottom}px ${drop.left}px`; glass.style.borderRadius = `${drop.radius}px`; if (shadow) shadow.style.opacity = smoothstep(0.3, 0.9, grown).toFixed(3); // The contents ride inside the glass, scaled with it and cut to it, and sharpen // as it lands. Closing, they go first. They follow the springs themselves, not // the glass's whole pixels: placed by its rounded box while scaling smoothly, // they jittered half a pixel every frame. const leaving = closing ? 1 - smoothstep(0, CONTENT_OUT, elapsed) : 1; const exactW = Math.max(1, width.value); const exactH = Math.max(1, height.value); const exactGrown = Math.max(0, Math.min(exactW / fullW, exactH / fullH)); const shown = Math.min(smoothstep(0.35, 0.85, exactGrown), leaving); const sharp = Math.min(smoothstep(0.78, 1, exactGrown), leaving); const scale = Math.min(1, Math.max(0.5, exactGrown)); const centerX = anchorX.value + (0.5 - anchorShare.x) * exactW; const centerY = anchorY.value + (0.5 - anchorShare.y) * exactH; const finalX = (menu.left + menu.right) / 2; const finalY = (menu.top + menu.bottom) / 2; for (const [child, place] of placements) { child.style.opacity = shown.toFixed(3); child.style.filter = sharp < 1 ? `blur(${((1 - sharp) * CONTENT_BLUR).toFixed(2)}px)` : ""; child.style.transformOrigin = `${finalX - place.x}px ${finalY - place.y}px`; child.style.translate = `${(centerX - finalX).toFixed(2)}px ${(centerY - finalY).toFixed(2)}px`; child.style.scale = scale.toFixed(4); // Cut to the drop's box, in the child's own unscaled box. const left = finalX + (drop.left - centerX) / scale - place.x; const top = finalY + (drop.top - centerY) / scale - place.y; const right = finalX + (drop.right - centerX) / scale - place.x; const bottom = finalY + (drop.bottom - centerY) / scale - place.y; child.style.clipPath = `inset(${top.toFixed(1)}px calc(100% - ${right.toFixed(1)}px) calc(100% - ${bottom.toFixed(1)}px) ${left.toFixed(1)}px round ${(drop.radius / scale).toFixed(1)}px)`; } // Back at its source: smaller than the button and into its glass, it is taken // back, and the glass the button sits in bulges as it takes it. const fromW = from.right - from.left; const fromH = from.bottom - from.top; if (closing && !returning && w <= fromW * SHOW_AT && h <= fromH * SHOW_AT) { returning = true; button.show(BUTTON_RETURN); } const takenBack = standalone() ? TAKEN_BACK : SUB_TAKEN_BACK; if (closing && !absorbed && w <= fromW * takenBack && h <= fromH * takenBack) { absorbed = true; flying = false; follow.disconnect(); glass.style.visibility = "hidden"; drawingBack.delete(hurry); button.show(BUTTON_RETURN); if (element() && standalone()) { kickGlass(element(), ABSORB_KICK); pulse(); } // Gone from sight, so gone: the caller hides the surface now. finish(true); } }; const settled = () => Math.abs(width.value - target.width) < 0.5 && Math.abs(height.value - target.height) < 0.5 && Math.abs(anchorX.value - target.x) < 0.5 && Math.abs(anchorY.value - target.y) < 0.5 && Math.abs(radius.value - menu.radius) < 0.5 && Math.abs(width.velocity) + Math.abs(height.velocity) + Math.abs(anchorX.velocity) + Math.abs(anchorY.velocity) < 40; const tick = (now: number) => { // Fixed steps, as the tabs take them, so the bounce is the same at any frame // rate. What's left of a frame carries over to the next one. carry += Math.min(Math.max(now - last, 0) / 1000, 0.05); last = now; for (; carry >= STEP; carry -= STEP) { elapsed += STEP; if (elapsed < motion.hold) continue; stepSpring(width, target.width, motion.width.spring, STEP); stepSpring(height, target.height, motion.height.spring, STEP); stepSpring(anchorX, target.x, motion.anchor.spring, STEP); stepSpring(anchorY, target.y, motion.anchor.spring, STEP); stepSpring(radius, wantedRadius(width.value, height.value), RADIUS, STEP); } paint(); if (absorbed) { frame = 0; return; } // Landed: the glass box sits exactly on the surface, so it can go back to // filling it by its own styles without a visible change. if (!closing && settled()) { rest(); surface.dispatchEvent(new CustomEvent(GLASS_LANDED, { bubbles: true })); finish(true); return; } frame = requestAnimationFrame(tick); }; const run = () => { if (frame) return; last = performance.now(); carry = 0; frame = requestAnimationFrame(tick); }; // At rest the glass box fills the surface by its own styles. function rest() { if (frame) cancelAnimationFrame(frame); drawingBack.delete(hurry); frame = 0; flying = false; glass.removeAttribute("data-flight"); for (const property of ["inset", "border-radius", "visibility"]) glass.style.removeProperty(property); shadow?.style.removeProperty("opacity"); for (const well of wells()) well.style.removeProperty("visibility"); for (const child of contents()) { for (const property of ["opacity", "filter", "scale", "translate", "transform-origin", "clip-path"]) { child.style.removeProperty(property); } } } // Starts the springs toward `to`, launching each by its share of the distance. const aim = (next: Motion, to: typeof target) => { motion = next; target = to; elapsed = 0; const launch = (spring: Spring, goal: number, axis: Axis) => { spring.velocity += (goal - spring.value) * axis.launch; }; launch(width, to.width, next.width); launch(height, to.height, next.height); launch(anchorX, to.x, next.anchor); launch(anchorY, to.y, next.anchor); }; const fly = () => { flying = true; absorbed = false; returning = false; glass.setAttribute("data-flight", ""); glass.style.removeProperty("visibility"); for (const well of wells()) well.style.setProperty("visibility", "hidden"); }; const toBox = (box: Box) => ({ width: box.right - box.left, height: box.bottom - box.top, x: box.left + anchorShare.x * (box.right - box.left), y: box.top + anchorShare.y * (box.bottom - box.top), }); const jump = (to: typeof target) => { width.value = to.width; height.value = to.height; anchorX.value = to.x; anchorY.value = to.y; radius.value = wantedRadius(to.width, to.height); for (const spring of [width, height, anchorX, anchorY, radius]) spring.velocity = 0; }; // A source that resizes as its surface closes, as a picker's does to show the new // choice, is followed: the drop draws back into its middle, not where it was. const follow = new ResizeObserver(() => { const trigger = element(); if (!closing || absorbed || !trigger) return; const box = trigger.getBoundingClientRect(); if (!box.width || !box.height) return; const bounds = surface.getBoundingClientRect(); from = { left: box.left - bounds.left, top: box.top - bounds.top, right: box.right - bounds.left, bottom: box.bottom - bounds.top, radius: from.radius }; sink = sinkInto(from); target = toBox(sink); }); // Another surface is pouring out: draw back the rest of the way quickly. function hurry() { if (!closing || absorbed) return; motion = HURRY; elapsed = Math.max(elapsed, CONTENT_OUT); } // The shadow is the glass box's while it pours, so it follows the drop. const castShadow = () => { if (shadow?.parentElement === glass) return; shadow = document.createElement("span"); shadow.className = "liquid-glass-shadow"; glass.prepend(shadow); surface.setAttribute("data-liquid-glass-morph", ""); }; return { /** * Pours the glass out of `origin`. Resolves true once it has landed, or false * if drawBack() interrupted it. */ pour(origin: GlassPourSource, next: GlassPourOptions = {}): Promise { const reopening = closing && flying && !absorbed && origin === source; // A draw back this interrupts never finishes. finish(false); const done = wait(); follow.disconnect(); drawingBack.delete(hurry); // Any other glass still drawing back gets out of the way. A sub-menu's parent // stays open, so only standalone surfaces hurry the others. if (!next.submenu) for (const other of drawingBack) other(); closing = false; surface.removeAttribute("data-liquid-glass-closing"); castShadow(); // Reopened while it drew back: it pours straight back out from wherever the // glass is, over its button again if it covered it. if (reopening) { if (covered) catcher.place(from); fly(); aim(standalone() ? OPEN : SUB_OPEN, toBox(menu)); run(); return done; } rest(); button.show(); source = origin; options = next; const trigger = element(); // Where it was placed, not where it last covered its button. if (surface.style.translate) translateInstantly(surface, null); const side = trigger ? (options.side ?? sideOf(surface.getBoundingClientRect(), trigger.getBoundingClientRect())) : null; // A surface moves over its button before it is measured, and the button fades // as the drop leaves it. covered = Boolean(options.cover && trigger && side && coverButton(surface, trigger, side)); if (covered && trigger) button.hide(trigger, still() ? 0 : BUTTON_FADE); // Nothing to grow from: the surface is simply there. if (!measure()) { finish(true); return done; } if (covered) catcher.place(from); if (still()) { finish(true); return done; } jump(toBox(from)); fly(); aim(standalone() ? OPEN : SUB_OPEN, toBox(menu)); paint(); run(); return done; }, /** * Draws the glass back into where it poured from. Resolves true once it has * gone, when the surface should be hidden, or false if pour() reopened it. */ drawBack(): Promise { if (closing) return wait(); // A pour this interrupts never lands. finish(false); const done = wait(); if (!source) { finish(true); return done; } closing = true; // Drawing back, it lets presses through to what it covered, its button among // them (styles.css). catcher.remove(); const wasFlying = flying; // With motion off, or a source that went away, it is simply gone. if (still() || !measure()) { rest(); button.show(); finish(true); return done; } if (!wasFlying) jump(toBox(menu)); surface.setAttribute("data-liquid-glass-closing", ""); fly(); aim(standalone() ? CLOSE : SUB_CLOSE, toBox(sink)); drawingBack.add(hurry); const trigger = standalone() ? element() : null; if (trigger) follow.observe(trigger); paint(); run(); return done; }, destroy() { follow.disconnect(); rest(); closing = false; button.show(); catcher.remove(); shadow?.remove(); shadow = null; surface.removeAttribute("data-liquid-glass-morph"); surface.removeAttribute("data-liquid-glass-closing"); surface.style.removeProperty("translate"); finish(false); }, }; }