import { LENS_PT, spring, STEP, stepSpring, SWELL, SWELL_PT, type SpringConfig } from "./spring.js"; /* The press, for any piece of glass: pressed, it swells about its centre on the tabs' bouncy spring and springs back with a slight overshoot on release, by the same points, so a field and a toolbar feel like one material. Held and dragged, it leans after the pointer like taffy, as iOS 27's glass controls do. */ // Small glass, like a lone round button, swells by at most this share of its size; // the swell in points would grow it by a quarter. iOS 27's glass buttons and macOS // 27's clicked glass grow by about a tenth. const MAX_SWELL = 0.1; // Held and dragged, the glass moves toward the pointer by up to LEAN of its size // (and LEAN_MAX px), stretching along the pull by STRETCH times its share of the // lean while it thins across it by THIN times that. It has weight: it takes RESIST // px of pointer travel for each px it gives at first, giving less the farther it // is pulled, and trails the pointer on a heavy spring. Let go, it springs back past // rest and wobbles once. const LEAN = 0.09; const LEAN_MAX = 7; const RESIST = 5; const STRETCH = 1.3; const THIN = 0.5; const LEAN_SPRING: SpringConfig = { omega: 20, zeta: 0.45 }; const HELD_SPRING: SpringConfig = { omega: 18, zeta: 0.9 }; /** The event that makes a swelling surface give or bulge (kickGlass). */ const KICK = "liquid-glass-kick"; /** On a surface that swells, so kicks can find it. */ export const SWELL_ATTRIBUTE = "data-liquid-glass-swell"; /** * Kicks the swell of the glass surface around `element`: positive bulges it, * negative makes it give, by that many swells per second on the swell's spring, so * it rings back to rest. A menu's glass drawing back into its button does this to * the glass the button sits in. */ export function kickGlass(element: Element | null, velocity: number) { element?.closest(`[${SWELL_ATTRIBUTE}]`)?.dispatchEvent(new CustomEvent(KICK, { detail: velocity })); } /** Swells `surface` while pressed and leans it after a drag. Returns a cleanup function. */ export function swellOnPress(surface: HTMLElement) { const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const swell = spring(); const leanX = spring(); const leanY = spring(); let target = 0; let targetX = 0; let targetY = 0; let origin: { x: number; y: number } | null = null; let width = 0; let height = 0; let frame = 0; let last = 0; let carry = 0; const measure = () => { width = surface.offsetWidth; height = surface.offsetHeight; }; const write = () => { const pt = Math.min(width, height) / LENS_PT || 1; const length = Math.max(width, height); const grow = Math.min((SWELL_PT * pt) / length, MAX_SWELL); const base = 1 + swell.value * grow; const shareX = width ? Math.abs(leanX.value) / width : 0; const shareY = height ? Math.abs(leanY.value) / height : 0; const scaleX = base * (1 + STRETCH * shareX - THIN * shareY); const scaleY = base * (1 + STRETCH * shareY - THIN * shareX); const still = Math.abs(swell.value) < 1e-4 && Math.abs(leanX.value) < 0.01 && Math.abs(leanY.value) < 0.01; surface.style.scale = still ? "" : `${scaleX.toFixed(5)} ${scaleY.toFixed(5)}`; surface.style.translate = still ? "" : `${leanX.value.toFixed(3)}px ${leanY.value.toFixed(3)}px`; }; 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; const lean = origin ? HELD_SPRING : LEAN_SPRING; for (; carry >= STEP; carry -= STEP) { stepSpring(swell, target, SWELL, STEP); stepSpring(leanX, targetX, lean, STEP); stepSpring(leanY, targetY, lean, STEP); } write(); const resting = target === 0 && targetX === 0 && targetY === 0 && Math.abs(swell.value) < 0.0005 && Math.abs(swell.velocity) < 0.01 && Math.abs(leanX.value) < 0.02 && Math.abs(leanY.value) < 0.02 && Math.abs(leanX.velocity) < 0.5 && Math.abs(leanY.velocity) < 0.5; if (resting) { swell.value = swell.velocity = 0; leanX.value = leanX.velocity = 0; leanY.value = leanY.velocity = 0; surface.style.scale = ""; surface.style.translate = ""; frame = 0; return; } frame = requestAnimationFrame(tick); }; const run = () => { if (frame) return; last = performance.now(); carry = 0; frame = requestAnimationFrame(tick); }; // Eases off toward `limit` the farther the pointer goes, like a rubber band. const rubber = (distance: number, limit: number) => (limit > 0 ? limit * Math.tanh(distance / (RESIST * limit)) : 0); const onMove = (event: PointerEvent) => { if (!origin) return; targetX = rubber(event.clientX - origin.x, Math.min(width * LEAN, LEAN_MAX)); targetY = rubber(event.clientY - origin.y, Math.min(height * LEAN, LEAN_MAX)); run(); }; const onRelease = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); origin = null; target = 0; targetX = 0; targetY = 0; run(); }; const onDown = (event: PointerEvent) => { if (event.button !== 0 || reduced.matches) return; // A press on a button that opens a menu or popover becomes that surface, which // is placed by the button: swelling, the glass moved the button, and the // surface followed it. The glass takes the drop back with a bulge instead // (pourFrom and drawBack). if (event.target instanceof Element && event.target.closest('[aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]')) return; measure(); origin = { x: event.clientX, y: event.clientY }; target = 1; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onRelease); window.addEventListener("pointercancel", onRelease); run(); }; const onKick = (event: Event) => { const velocity = (event as CustomEvent).detail; if (typeof velocity !== "number" || reduced.matches) return; if (!frame) measure(); swell.velocity += velocity; run(); }; surface.setAttribute(SWELL_ATTRIBUTE, ""); surface.addEventListener("pointerdown", onDown); surface.addEventListener(KICK, onKick); return () => { surface.removeAttribute(SWELL_ATTRIBUTE); surface.removeEventListener("pointerdown", onDown); surface.removeEventListener(KICK, onKick); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); if (frame) cancelAnimationFrame(frame); surface.style.scale = ""; surface.style.translate = ""; }; }