Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 4 |
| /* |
| 5 | * Glass menus and popovers pour out of the button that opened them and draw back |
| 6 | * into it, as iOS 27's do. Every number here was measured frame by frame from iOS |
| 7 | * 27's own menus (SwiftUI toolbar and iPad toolbar menus, and apps' menus). |
| 8 | * |
| 9 | * Opening, the button itself becomes the drop: the glass starts as the button, the |
| 10 | * button fades, and the drop inflates into the menu over it, its corner where the |
| 11 | * button was: its height leads and overshoots by 7%, its width follows and |
| 12 | * overshoots by 2.5%, and it settles in about 450 ms. Closing, the contents go |
| 13 | * first and the glass rounds into a drop that shrinks back into the button, which |
| 14 | * reappears with a bulge, in about 200 ms. A sub-menu grows the same way out of |
| 15 | * its row, beside the menu. |
| 16 | * |
| 17 | * What flies is the surface's own glass, so it is the same glass on every frame as |
| 18 | * at rest. Only its box and corners move (the glass breaks if it scales), shaped by |
| 19 | * border-radius alone, as at rest, so every GPU draws it the same. |
| 20 | */ |
| 21 |
| 22 | /** A side of the source the surface sits on. */ |
| 23 | export type GlassSide = "top" | "right" | "bottom" | "left"; |
| 24 |
| 25 | /** What glass pours out of: the button or field that opened it, or a point, for a context menu. */ |
| 26 | export type GlassPourSource = Element | { x: number; y: number }; |
| 27 |
| 28 | export type GlassPourOptions = { |
| 29 | /** |
| 30 | * Move the surface over the button that opened it, so the button becomes the |
| 31 | * menu, as iOS 27's menus do: the surface moves back along its side of the button |
| 32 | * until it covers it. For buttons up to 56px tall; a taller source (a card, a |
| 33 | * row) keeps its surface beside it. Leave it off for popovers anchored to a field |
| 34 | * they mustn't hide. |
| 35 | */ |
| 36 | cover?: boolean | undefined; |
| 37 | /** Which side of the source the surface sits on. Measured when left out. */ |
| 38 | side?: GlassSide | undefined; |
| 39 | /** |
| 40 | * A sub-menu, which unfolds out of its own edge beside the row it came from, in |
| 41 | * about 200 ms, and folds back in about 120, so the menu being hovered stays in |
| 42 | * view. |
| 43 | */ |
| 44 | submenu?: boolean | undefined; |
| 45 | }; |
| 46 |
| 47 | type Box = { left: number; top: number; right: number; bottom: number; radius: number }; |
| 48 | type Axis = { spring: SpringConfig; launch: number }; |
| 49 | type Motion = { width: Axis; height: Axis; anchor: Axis; hold: number }; |
| 50 |
| 51 | // Opening, fitted to the SwiftUI toolbar menu's size every frame, launched a little |
| 52 | // softer than it is, so the drop leaves the button small and inflates over five |
| 53 | // frames rather than two. |
| 54 | const OPEN: Motion = { |
| 55 | width: { spring: { omega: 15, zeta: 0.79 }, launch: 7 }, |
| 56 | height: { spring: { omega: 21, zeta: 0.71 }, launch: 13 }, |
| 57 | anchor: { spring: { omega: 22, zeta: 0.86 }, launch: 4 }, |
| 58 | hold: 0, |
| 59 | }; |
| 60 | // Closing: the contents go in a frame, then the glass rounds into a drop that |
| 61 | // narrows faster than it shortens and rises into the button, taken back while |
| 62 | // still moving, by about 200 ms. |
| 63 | const CLOSE: Motion = { |
| 64 | width: { spring: { omega: 21, zeta: 0.9 }, launch: 3 }, |
| 65 | height: { spring: { omega: 18, zeta: 0.9 }, launch: 2.5 }, |
| 66 | anchor: { spring: { omega: 23, zeta: 0.95 }, launch: 2 }, |
| 67 | hold: 0.03, |
| 68 | }; |
| 69 | // A drop still drawing back when another surface starts pouring out hurries home |
| 70 | // on these, so the two don't hang over each other. |
| 71 | const HURRY: Motion = { |
| 72 | width: { spring: { omega: 30, zeta: 0.9 }, launch: 0 }, |
| 73 | height: { spring: { omega: 26, zeta: 0.9 }, launch: 0 }, |
| 74 | anchor: { spring: { omega: 32, zeta: 0.95 }, launch: 0 }, |
| 75 | hold: 0, |
| 76 | }; |
| 77 | const SUB_OPEN: Motion = { |
| 78 | width: { spring: { omega: 26, zeta: 0.82 }, launch: 6 }, |
| 79 | height: { spring: { omega: 28, zeta: 0.78 }, launch: 8 }, |
| 80 | anchor: { spring: { omega: 30, zeta: 0.9 }, launch: 0 }, |
| 81 | hold: 0, |
| 82 | }; |
| 83 | const SUB_CLOSE: Motion = { |
| 84 | width: { spring: { omega: 34, zeta: 0.95 }, launch: 2 }, |
| 85 | height: { spring: { omega: 32, zeta: 0.95 }, launch: 2 }, |
| 86 | anchor: { spring: { omega: 34, zeta: 1 }, launch: 0 }, |
| 87 | hold: 0, |
| 88 | }; |
| 89 | // The drop a sub-menu unfolds from: this share of its row's height, round, at its |
| 90 | // own edge. Folding back, the edge takes it once it is this many times that drop, |
| 91 | // so no dot lingers there as the spring slows. |
| 92 | const SUB_DROP = 0.7; |
| 93 | const SUB_TAKEN_BACK = 1.3; |
| 94 | // Closing, the drop heads for this share of its source's size, centred on it. The |
| 95 | // button it covered fades back in as the drop reaches the button's size, and the |
| 96 | // drop is taken back once it is this share of the button, still moving: taken at |
| 97 | // the button's size, the glass vanished and the button appeared in the same frame. |
| 98 | const SINK = 0.12; |
| 99 | const SHOW_AT = 1.15; |
| 100 | const TAKEN_BACK = 0.3; |
| 101 | const BUTTON_RETURN = 110; |
| 102 | // A surface covers a button no taller than this. |
| 103 | const COVER_MAX = 56; |
| 104 | // A second press this soon after releasing the one that opened a menu over its |
| 105 | // button is a double-click on the button, and closes the menu. |
| 106 | const DOUBLE_PRESS = 250; |
| 107 | // The button fades as the drop leaves it. |
| 108 | const BUTTON_FADE = 80; |
| 109 | /** |
| 110 | * The corner radii glass passes through as it pours out, whole pixels from 4 to 48. |
| 111 | * Built ahead of time (prewarmLiquidGlass), the rim is drawn for exactly the |
| 112 | * corners it has on every frame instead of stretched from another radius. |
| 113 | */ |
| 114 | export const MORPH_RADII = Array.from({ length: 45 }, (_, index) => 4 + index); |
| 115 | const MAX_RADIUS = 48; |
| 116 | // The corners follow their own critically damped spring: snapped to a coarser |
| 117 | // ladder, or tightened to the surface's in a few frames as it closed, they jumped |
| 118 | // up to 27 px in a frame. |
| 119 | const RADIUS: SpringConfig = { omega: 45, zeta: 1 }; |
| 120 | // The softest a source's corners start, so square buttons still pour out as liquid. |
| 121 | const MIN_SOURCE_RADIUS = 8; |
| 122 | // A context menu blooms from a drop this wide under the pointer. |
| 123 | const POINTER_DROP = 24; |
| 124 | // The contents blur by this much while the glass is small... |
| 125 | const CONTENT_BLUR = 5; |
| 126 | // ...and, closing, are gone in this many seconds. |
| 127 | const CONTENT_OUT = 0.06; |
| 128 | // A button swells as the drop comes back into it, and the glass it sits in bulges. |
| 129 | const SOURCE_SWELL = 1.06; |
| 130 | const SOURCE_EASE = "cubic-bezier(0.3, 0.7, 0.4, 1)"; |
| 131 | const ABSORB_KICK = 22; |
| 132 |
| 133 | /** Sent from a surface once its glass has landed and its contents are back at rest. */ |
| 134 | export const GLASS_LANDED = "liquid-glass-landed"; |
| 135 |
| 136 | function smoothstep(from: number, to: number, value: number) { |
| 137 | const t = Math.min(1, Math.max(0, (value - from) / (to - from))); |
| 138 | return t * t * (3 - 2 * t); |
| 139 | } |
| 140 |
| 141 | function cornerRadius(element: Element, width: number, height: number) { |
| 142 | const radius = parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0; |
| 143 | return Math.min(radius, width / 2, height / 2); |
| 144 | } |
| 145 |
| 146 | function still() { |
| 147 | return matchMedia("(prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce), (forced-colors: active)").matches; |
| 148 | } |
| 149 |
| 150 | // What ends a press, including the window losing focus mid-press. An element losing |
| 151 | // focus also reaches a capturing window listener as a blur, and doesn't. |
| 152 | const PRESS_ENDS = ["pointerup", "pointercancel", "blur"] as const; |
| 153 | const endsPress = (event: Event) => event.type !== "blur" || event.target === window; |
| 154 |
| 155 | // Whether a press is held, so a menu that press opened knows its release is still |
| 156 | // to come (catcher). Noted from the start, since a menu mounts after the press. |
| 157 | let pressHeld = false; |
| 158 | if (typeof window !== "undefined") { |
| 159 | window.addEventListener("pointerdown", event => { |
| 160 | if (event.button === 0) pressHeld = true; |
| 161 | }, { capture: true, passive: true }); |
| 162 | for (const type of PRESS_ENDS) { |
| 163 | window.addEventListener(type, event => { |
| 164 | if (endsPress(event)) pressHeld = false; |
| 165 | }, { capture: true, passive: true }); |
| 166 | } |
| 167 | } |
| 168 |
| 169 | // The drops drawing back now, each with its way to hurry. |
| 170 | const drawingBack = new Set<() => void>(); |
| 171 |
| 172 | /** The side of `source` that `surface` sits on, from where they are. */ |
| 173 | function sideOf(surface: DOMRect, source: DOMRect): GlassSide { |
| 174 | const gaps: [GlassSide, number][] = [ |
| 175 | ["bottom", surface.top - source.bottom], |
| 176 | ["top", source.top - surface.bottom], |
| 177 | ["right", surface.left - source.right], |
| 178 | ["left", source.left - surface.right], |
| 179 | ]; |
| 180 | // The side it is furthest out on; overlapping sources count as below. |
| 181 | const [side, gap] = gaps.reduce((best, next) => (next[1] > best[1] ? next : best)); |
| 182 | return gap > -4 ? side : "bottom"; |
| 183 | } |
| 184 |
| 185 | /** |
| 186 | * Moves a surface by `translate` at once, even one that transitions its translate: |
| 187 | * style is flushed with transitions off, then they come back for what follows. |
| 188 | */ |
| 189 | function translateInstantly(surface: HTMLElement, translate: string | null) { |
| 190 | const transition = surface.style.transitionProperty; |
| 191 | surface.style.transitionProperty = "none"; |
| 192 | if (translate) surface.style.translate = translate; |
| 193 | else surface.style.removeProperty("translate"); |
| 194 | surface.getBoundingClientRect(); |
| 195 | surface.style.transitionProperty = transition; |
| 196 | } |
| 197 |
| 198 | /** |
| 199 | * Moves a surface, placed beside the button that opened it, over that button: it |
| 200 | * moves back along its side of the button until its near edge is on the button's |
| 201 | * far one, so the button's corner is the surface's. Returns whether it covers it. |
| 202 | */ |
| 203 | function coverButton(surface: HTMLElement, button: Element, side: GlassSide) { |
| 204 | const box = button.getBoundingClientRect(); |
| 205 | if (!box.height || box.height > COVER_MAX) return false; |
| 206 | const menu = surface.getBoundingClientRect(); |
| 207 | const dx = side === "right" ? box.left - menu.left : side === "left" ? box.right - menu.right : 0; |
| 208 | const dy = side === "bottom" ? box.top - menu.top : side === "top" ? box.bottom - menu.bottom : 0; |
| 209 | translateInstantly(surface, `${dx.toFixed(2)}px ${dy.toFixed(2)}px`); |
| 210 | return true; |
| 211 | } |
| 212 |
| 213 | /** |
| 214 | * Over the button a surface covers, a catcher keeps the press that opened it: menus |
| 215 | * choose the item a press is released over, so letting go where the button was |
| 216 | * would choose the item that now lies there. It goes shortly after that release, so |
| 217 | * the rows beneath it can be hovered and chosen; a second press before then is a |
| 218 | * double-click on the button and closes the menu with Escape. A surface opened with |
| 219 | * no press held, as from the keyboard, gets none. |
| 220 | */ |
| 221 | function buttonCatcher(surface: HTMLElement) { |
| 222 | let catcher: HTMLElement | null = null; |
| 223 | let timer = 0; |
| 224 | const listen = (on: boolean) => { |
| 225 | for (const type of PRESS_ENDS) { |
| 226 | if (on) window.addEventListener(type, release, true); |
| 227 | else window.removeEventListener(type, release, true); |
| 228 | } |
| 229 | }; |
| 230 | const remove = () => { |
| 231 | listen(false); |
| 232 | window.clearTimeout(timer); |
| 233 | catcher?.remove(); |
| 234 | }; |
| 235 | function release(event: Event) { |
| 236 | if (!endsPress(event)) return; |
| 237 | listen(false); |
| 238 | window.clearTimeout(timer); |
| 239 | timer = window.setTimeout(remove, DOUBLE_PRESS); |
| 240 | } |
| 241 | return { |
| 242 | place(box: { left: number; top: number; right: number; bottom: number }) { |
| 243 | const placed = catcher?.parentElement === surface; |
| 244 | if (!placed && !pressHeld) return; |
| 245 | if (!catcher) { |
| 246 | catcher = document.createElement("span"); |
| 247 | catcher.className = "liquid-glass-catcher"; |
| 248 | catcher.setAttribute("aria-hidden", "true"); |
| 249 | catcher.addEventListener("pointerdown", event => { |
| 250 | if (event.button !== 0) return; |
| 251 | event.preventDefault(); |
| 252 | event.stopPropagation(); |
| 253 | surface.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); |
| 254 | }); |
| 255 | } |
| 256 | catcher.style.left = `${box.left}px`; |
| 257 | catcher.style.top = `${box.top}px`; |
| 258 | catcher.style.width = `${box.right - box.left}px`; |
| 259 | catcher.style.height = `${box.bottom - box.top}px`; |
| 260 | if (placed) return; |
| 261 | window.clearTimeout(timer); |
| 262 | surface.append(catcher); |
| 263 | listen(true); |
| 264 | }, |
| 265 | remove, |
| 266 | }; |
| 267 | } |
| 268 |
| 269 | /** Hides a button while the surface it became is out; `show` brings it back, over `fade` ms. */ |
| 270 | function buttonHider() { |
| 271 | let hidden: { button: Element; animation: Animation } | null = null; |
| 272 | return { |
| 273 | hide(button: Element, fade: number) { |
| 274 | if (hidden) return; |
| 275 | hidden = { button, animation: button.animate([{ opacity: 1 }, { opacity: 0 }], { duration: fade, easing: "ease-out", fill: "forwards" }) }; |
| 276 | }, |
| 277 | show(fade = 0) { |
| 278 | if (!hidden) return; |
| 279 | const { button, animation } = hidden; |
| 280 | hidden = null; |
| 281 | animation.cancel(); |
| 282 | if (fade > 0) button.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fade, easing: "ease-out" }); |
| 283 | }, |
| 284 | }; |
| 285 | } |
| 286 |
| 287 | /** |
| 288 | * Pours `glass`, the glass element of `surface`, out of a source and draws it back. |
| 289 | * Moves the glass box and the surface's other children itself. |
| 290 | */ |
| 291 | export function createGlassMorph(glass: HTMLElement, surface: HTMLElement) { |
| 292 | const button = buttonHider(); |
| 293 | const catcher = buttonCatcher(surface); |
| 294 |
| 295 | const width: Spring = { value: 0, velocity: 0 }; |
| 296 | const height: Spring = { value: 0, velocity: 0 }; |
| 297 | const anchorX: Spring = { value: 0, velocity: 0 }; |
| 298 | const anchorY: Spring = { value: 0, velocity: 0 }; |
| 299 | const radius: Spring = { value: 0, velocity: 0 }; |
| 300 | let motion = OPEN; |
| 301 | let target = { width: 0, height: 0, x: 0, y: 0 }; |
| 302 | // Everything below is in the surface's coordinates, from the last measure. |
| 303 | let menu: Box = { left: 0, top: 0, right: 0, bottom: 0, radius: 0 }; |
| 304 | let from: Box = { ...menu }; |
| 305 | // Where it heads closing: the middle of its source. |
| 306 | let sink: Box = { ...menu }; |
| 307 | let anchorShare = { x: 0, y: 0 }; |
| 308 | let placements = new Map<HTMLElement, { x: number; y: number }>(); |
| 309 | let source: GlassPourSource | null = null; |
| 310 | let options: GlassPourOptions = {}; |
| 311 | // A point source, in the surface's coordinates when it opened. |
| 312 | let pointFrom: { x: number; y: number } | null = null; |
| 313 | let covered = false; |
| 314 | let closing = false; |
| 315 | let flying = false; |
| 316 | let elapsed = 0; |
| 317 | let absorbed = false; |
| 318 | // Closing, whether the covered button has started fading back in. |
| 319 | let returning = false; |
| 320 | let frame = 0; |
| 321 | let last = 0; |
| 322 | let carry = 0; |
| 323 | let shadow: HTMLElement | null = null; |
| 324 | // Callers waiting on the current pour or draw back: true once it finishes, false |
| 325 | // if the other one interrupts it. |
| 326 | let waiting: ((done: boolean) => void)[] = []; |
| 327 |
| 328 | const element = () => (source instanceof Element ? source : null); |
| 329 | const standalone = () => !options.submenu; |
| 330 | // The hover pill is a backdrop filter, which can't fade; it hides in flight. |
| 331 | const wells = () => Array.from(surface.children).filter((child): child is HTMLElement => child instanceof HTMLElement && child.classList.contains("liquid-glass-well")); |
| 332 | const contents = () => |
| 333 | Array.from(surface.children).filter( |
| 334 | (child): child is HTMLElement => |
| 335 | child instanceof HTMLElement && |
| 336 | child !== glass && |
| 337 | !child.classList.contains("liquid-glass-well") && |
| 338 | !child.classList.contains("liquid-glass-catcher"), |
| 339 | ); |
| 340 |
| 341 | const finish = (done: boolean) => { |
| 342 | const callers = waiting; |
| 343 | waiting = []; |
| 344 | for (const resolve of callers) resolve(done); |
| 345 | }; |
| 346 | const wait = () => new Promise<boolean>(resolve => waiting.push(resolve)); |
| 347 |
| 348 | // Closing, the drop heads for the middle of its source. |
| 349 | const sinkInto = (box: Box): Box => { |
| 350 | const cx = (box.left + box.right) / 2; |
| 351 | const cy = (box.top + box.bottom) / 2; |
| 352 | const sw = (box.right - box.left) * SINK; |
| 353 | const sh = (box.bottom - box.top) * SINK; |
| 354 | return { left: cx - sw / 2, top: cy - sh / 2, right: cx + sw / 2, bottom: cy + sh / 2, radius: Math.min(sw, sh) / 2 }; |
| 355 | }; |
| 356 |
| 357 | // Reads the source and the surface. Every layout read happens here, so frames only write. |
| 358 | const measure = () => { |
| 359 | const bounds = surface.getBoundingClientRect(); |
| 360 | menu = { left: 0, top: 0, right: bounds.width, bottom: bounds.height, radius: cornerRadius(surface, bounds.width, bounds.height) }; |
| 361 | const trigger = element(); |
| 362 | let side: GlassSide | null = null; |
| 363 | if (!trigger) { |
| 364 | // A context menu: the drop starts at the point, or where it opened, closing. |
| 365 | const point = source && !(source instanceof Element) ? source : { x: bounds.left, y: bounds.top }; |
| 366 | const x = closing && pointFrom ? pointFrom.x : Math.min(Math.max(point.x - bounds.left, 0), bounds.width); |
| 367 | const y = closing && pointFrom ? pointFrom.y : Math.min(Math.max(point.y - bounds.top, 0), bounds.height); |
| 368 | pointFrom = { x, y }; |
| 369 | const half = POINTER_DROP / 2; |
| 370 | from = { left: x - half, top: y - half, right: x + half, bottom: y + half, radius: half }; |
| 371 | const small = half * SINK; |
| 372 | sink = { left: x - small, top: y - small, right: x + small, bottom: y + small, radius: small }; |
| 373 | } else { |
| 374 | const box = trigger.getBoundingClientRect(); |
| 375 | if (!box.width || !box.height) return false; |
| 376 | side = options.side ?? sideOf(bounds, box); |
| 377 | const corner = Math.max(cornerRadius(trigger, box.width, box.height), Math.min(MIN_SOURCE_RADIUS, box.width / 2, box.height / 2)); |
| 378 | // The glass starts as the whole of what was clicked: the button becomes the |
| 379 | // drop. A sub-menu instead unfolds from a drop on its own near edge, level |
| 380 | // with its row: grown from the row itself, it slid across the menu as a |
| 381 | // slab, hiding the rows the pointer was moving to. |
| 382 | from = { left: box.left - bounds.left, top: box.top - bounds.top, right: box.right - bounds.left, bottom: box.bottom - bounds.top, radius: corner }; |
| 383 | if (!standalone() && (side === "left" || side === "right")) { |
| 384 | const size = (from.bottom - from.top) * SUB_DROP; |
| 385 | const cy = (from.top + from.bottom) / 2; |
| 386 | const edge = side === "right" ? menu.left : menu.right; |
| 387 | const left = side === "right" ? edge : edge - size; |
| 388 | from = { left, right: left + size, top: cy - size / 2, bottom: cy + size / 2, radius: size / 2 }; |
| 389 | } |
| 390 | sink = sinkInto(from); |
| 391 | } |
| 392 |
| 393 | // The drop grows from the surface's corner or edge nearest the source. |
| 394 | const share = (value: number, start: number, size: number) => Math.min(1, Math.max(0, (value - start) / size)); |
| 395 | anchorShare = { |
| 396 | x: side === "left" ? 1 : side === "right" ? 0 : share((from.left + from.right) / 2, menu.left, menu.right - menu.left), |
| 397 | y: side === "top" ? 1 : side === "bottom" ? 0 : share((from.top + from.bottom) / 2, menu.top, menu.bottom - menu.top), |
| 398 | }; |
| 399 | placements = new Map(contents().map(child => [child, { x: child.offsetLeft - surface.scrollLeft, y: child.offsetTop - surface.scrollTop }])); |
| 400 | return true; |
| 401 | }; |
| 402 |
| 403 | // The corners the drop wants at a size: round while it flies, tightening to the |
| 404 | // surface's as it lands. Closing, it rounds into a drop soon after it starts to |
| 405 | // shrink, rather than shrinking as a slab of the menu. |
| 406 | const wantedRadius = (w: number, h: number) => { |
| 407 | const fullW = menu.right - menu.left; |
| 408 | const fullH = menu.bottom - menu.top; |
| 409 | const landed = smoothstep(closing ? 0.85 : 0.75, 1, Math.min(w / fullW, h / fullH)); |
| 410 | const round = Math.min(Math.min(w, h) / 2, Math.max(MAX_RADIUS, menu.radius)); |
| 411 | return Math.max(0, Math.min(round + (menu.radius - round) * landed, Math.min(w, h) / 2)); |
| 412 | }; |
| 413 |
| 414 | // Whole pixels, as the glass's refraction is fitted to them, and so are the |
| 415 | // corners, which its rim is built for. |
| 416 | const rect = (): Box => { |
| 417 | const w = Math.max(1, Math.round(width.value)); |
| 418 | const h = Math.max(1, Math.round(height.value)); |
| 419 | const left = Math.round(anchorX.value - anchorShare.x * w); |
| 420 | const top = Math.round(anchorY.value - anchorShare.y * h); |
| 421 | const corner = Math.round(Math.min(Math.max(radius.value, 0), Math.min(w, h) / 2)); |
| 422 | return { left, top, right: left + w, bottom: top + h, radius: corner }; |
| 423 | }; |
| 424 |
| 425 | const pulse = () => { |
| 426 | element()?.animate([{ scale: "1" }, { scale: String(SOURCE_SWELL), offset: 0.35 }, { scale: "1" }], { duration: 420, easing: SOURCE_EASE }); |
| 427 | }; |
| 428 |
| 429 | const paint = () => { |
| 430 | const drop = rect(); |
| 431 | const fullW = menu.right - menu.left; |
| 432 | const fullH = menu.bottom - menu.top; |
| 433 | const w = drop.right - drop.left; |
| 434 | const h = drop.bottom - drop.top; |
| 435 | const grown = Math.max(0, Math.min(w / fullW, h / fullH)); |
| 436 |
| 437 | // The glass box is the drop: moved and sized through its inset, rounded by its |
| 438 | // corners, which every glass layer inherits. |
| 439 | glass.style.inset = `${drop.top}px ${fullW - drop.right}px ${fullH - drop.bottom}px ${drop.left}px`; |
| 440 | glass.style.borderRadius = `${drop.radius}px`; |
| 441 | if (shadow) shadow.style.opacity = smoothstep(0.3, 0.9, grown).toFixed(3); |
| 442 |
| 443 | // The contents ride inside the glass, scaled with it and cut to it, and sharpen |
| 444 | // as it lands. Closing, they go first. They follow the springs themselves, not |
| 445 | // the glass's whole pixels: placed by its rounded box while scaling smoothly, |
| 446 | // they jittered half a pixel every frame. |
| 447 | const leaving = closing ? 1 - smoothstep(0, CONTENT_OUT, elapsed) : 1; |
| 448 | const exactW = Math.max(1, width.value); |
| 449 | const exactH = Math.max(1, height.value); |
| 450 | const exactGrown = Math.max(0, Math.min(exactW / fullW, exactH / fullH)); |
| 451 | const shown = Math.min(smoothstep(0.35, 0.85, exactGrown), leaving); |
| 452 | const sharp = Math.min(smoothstep(0.78, 1, exactGrown), leaving); |
| 453 | const scale = Math.min(1, Math.max(0.5, exactGrown)); |
| 454 | const centerX = anchorX.value + (0.5 - anchorShare.x) * exactW; |
| 455 | const centerY = anchorY.value + (0.5 - anchorShare.y) * exactH; |
| 456 | const finalX = (menu.left + menu.right) / 2; |
| 457 | const finalY = (menu.top + menu.bottom) / 2; |
| 458 | for (const [child, place] of placements) { |
| 459 | child.style.opacity = shown.toFixed(3); |
| 460 | child.style.filter = sharp < 1 ? `blur(${((1 - sharp) * CONTENT_BLUR).toFixed(2)}px)` : ""; |
| 461 | child.style.transformOrigin = `${finalX - place.x}px ${finalY - place.y}px`; |
| 462 | child.style.translate = `${(centerX - finalX).toFixed(2)}px ${(centerY - finalY).toFixed(2)}px`; |
| 463 | child.style.scale = scale.toFixed(4); |
| 464 | // Cut to the drop's box, in the child's own unscaled box. |
| 465 | const left = finalX + (drop.left - centerX) / scale - place.x; |
| 466 | const top = finalY + (drop.top - centerY) / scale - place.y; |
| 467 | const right = finalX + (drop.right - centerX) / scale - place.x; |
| 468 | const bottom = finalY + (drop.bottom - centerY) / scale - place.y; |
| 469 | 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)`; |
| 470 | } |
| 471 |
| 472 | // Back at its source: smaller than the button and into its glass, it is taken |
| 473 | // back, and the glass the button sits in bulges as it takes it. |
| 474 | const fromW = from.right - from.left; |
| 475 | const fromH = from.bottom - from.top; |
| 476 | if (closing && !returning && w <= fromW * SHOW_AT && h <= fromH * SHOW_AT) { |
| 477 | returning = true; |
| 478 | button.show(BUTTON_RETURN); |
| 479 | } |
| 480 | const takenBack = standalone() ? TAKEN_BACK : SUB_TAKEN_BACK; |
| 481 | if (closing && !absorbed && w <= fromW * takenBack && h <= fromH * takenBack) { |
| 482 | absorbed = true; |
| 483 | flying = false; |
| 484 | follow.disconnect(); |
| 485 | glass.style.visibility = "hidden"; |
| 486 | drawingBack.delete(hurry); |
| 487 | button.show(BUTTON_RETURN); |
| 488 | if (element() && standalone()) { |
| 489 | kickGlass(element(), ABSORB_KICK); |
| 490 | pulse(); |
| 491 | } |
| 492 | // Gone from sight, so gone: the caller hides the surface now. |
| 493 | finish(true); |
| 494 | } |
| 495 | }; |
| 496 |
| 497 | const settled = () => |
| 498 | Math.abs(width.value - target.width) < 0.5 && |
| 499 | Math.abs(height.value - target.height) < 0.5 && |
| 500 | Math.abs(anchorX.value - target.x) < 0.5 && |
| 501 | Math.abs(anchorY.value - target.y) < 0.5 && |
| 502 | Math.abs(radius.value - menu.radius) < 0.5 && |
| 503 | Math.abs(width.velocity) + Math.abs(height.velocity) + Math.abs(anchorX.velocity) + Math.abs(anchorY.velocity) < 40; |
| 504 |
| 505 | const tick = (now: number) => { |
| 506 | // Fixed steps, as the tabs take them, so the bounce is the same at any frame |
| 507 | // rate. What's left of a frame carries over to the next one. |
| 508 | carry += Math.min(Math.max(now - last, 0) / 1000, 0.05); |
| 509 | last = now; |
| 510 | for (; carry >= STEP; carry -= STEP) { |
| 511 | elapsed += STEP; |
| 512 | if (elapsed < motion.hold) continue; |
| 513 | stepSpring(width, target.width, motion.width.spring, STEP); |
| 514 | stepSpring(height, target.height, motion.height.spring, STEP); |
| 515 | stepSpring(anchorX, target.x, motion.anchor.spring, STEP); |
| 516 | stepSpring(anchorY, target.y, motion.anchor.spring, STEP); |
| 517 | stepSpring(radius, wantedRadius(width.value, height.value), RADIUS, STEP); |
| 518 | } |
| 519 | paint(); |
| 520 | if (absorbed) { |
| 521 | frame = 0; |
| 522 | return; |
| 523 | } |
| 524 | // Landed: the glass box sits exactly on the surface, so it can go back to |
| 525 | // filling it by its own styles without a visible change. |
| 526 | if (!closing && settled()) { |
| 527 | rest(); |
| 528 | surface.dispatchEvent(new CustomEvent(GLASS_LANDED, { bubbles: true })); |
| 529 | finish(true); |
| 530 | return; |
| 531 | } |
| 532 | frame = requestAnimationFrame(tick); |
| 533 | }; |
| 534 |
| 535 | const run = () => { |
| 536 | if (frame) return; |
| 537 | last = performance.now(); |
| 538 | carry = 0; |
| 539 | frame = requestAnimationFrame(tick); |
| 540 | }; |
| 541 |
| 542 | // At rest the glass box fills the surface by its own styles. |
| 543 | function rest() { |
| 544 | if (frame) cancelAnimationFrame(frame); |
| 545 | drawingBack.delete(hurry); |
| 546 | frame = 0; |
| 547 | flying = false; |
| 548 | glass.removeAttribute("data-flight"); |
| 549 | for (const property of ["inset", "border-radius", "visibility"]) glass.style.removeProperty(property); |
| 550 | shadow?.style.removeProperty("opacity"); |
| 551 | for (const well of wells()) well.style.removeProperty("visibility"); |
| 552 | for (const child of contents()) { |
| 553 | for (const property of ["opacity", "filter", "scale", "translate", "transform-origin", "clip-path"]) { |
| 554 | child.style.removeProperty(property); |
| 555 | } |
| 556 | } |
| 557 | } |
| 558 |
| 559 | // Starts the springs toward `to`, launching each by its share of the distance. |
| 560 | const aim = (next: Motion, to: typeof target) => { |
| 561 | motion = next; |
| 562 | target = to; |
| 563 | elapsed = 0; |
| 564 | const launch = (spring: Spring, goal: number, axis: Axis) => { |
| 565 | spring.velocity += (goal - spring.value) * axis.launch; |
| 566 | }; |
| 567 | launch(width, to.width, next.width); |
| 568 | launch(height, to.height, next.height); |
| 569 | launch(anchorX, to.x, next.anchor); |
| 570 | launch(anchorY, to.y, next.anchor); |
| 571 | }; |
| 572 |
| 573 | const fly = () => { |
| 574 | flying = true; |
| 575 | absorbed = false; |
| 576 | returning = false; |
| 577 | glass.setAttribute("data-flight", ""); |
| 578 | glass.style.removeProperty("visibility"); |
| 579 | for (const well of wells()) well.style.setProperty("visibility", "hidden"); |
| 580 | }; |
| 581 |
| 582 | const toBox = (box: Box) => ({ |
| 583 | width: box.right - box.left, |
| 584 | height: box.bottom - box.top, |
| 585 | x: box.left + anchorShare.x * (box.right - box.left), |
| 586 | y: box.top + anchorShare.y * (box.bottom - box.top), |
| 587 | }); |
| 588 | const jump = (to: typeof target) => { |
| 589 | width.value = to.width; |
| 590 | height.value = to.height; |
| 591 | anchorX.value = to.x; |
| 592 | anchorY.value = to.y; |
| 593 | radius.value = wantedRadius(to.width, to.height); |
| 594 | for (const spring of [width, height, anchorX, anchorY, radius]) spring.velocity = 0; |
| 595 | }; |
| 596 |
| 597 | // A source that resizes as its surface closes, as a picker's does to show the new |
| 598 | // choice, is followed: the drop draws back into its middle, not where it was. |
| 599 | const follow = new ResizeObserver(() => { |
| 600 | const trigger = element(); |
| 601 | if (!closing || absorbed || !trigger) return; |
| 602 | const box = trigger.getBoundingClientRect(); |
| 603 | if (!box.width || !box.height) return; |
| 604 | const bounds = surface.getBoundingClientRect(); |
| 605 | from = { left: box.left - bounds.left, top: box.top - bounds.top, right: box.right - bounds.left, bottom: box.bottom - bounds.top, radius: from.radius }; |
| 606 | sink = sinkInto(from); |
| 607 | target = toBox(sink); |
| 608 | }); |
| 609 |
| 610 | // Another surface is pouring out: draw back the rest of the way quickly. |
| 611 | function hurry() { |
| 612 | if (!closing || absorbed) return; |
| 613 | motion = HURRY; |
| 614 | elapsed = Math.max(elapsed, CONTENT_OUT); |
| 615 | } |
| 616 |
| 617 | // The shadow is the glass box's while it pours, so it follows the drop. |
| 618 | const castShadow = () => { |
| 619 | if (shadow?.parentElement === glass) return; |
| 620 | shadow = document.createElement("span"); |
| 621 | shadow.className = "liquid-glass-shadow"; |
| 622 | glass.prepend(shadow); |
| 623 | surface.setAttribute("data-liquid-glass-morph", ""); |
| 624 | }; |
| 625 |
| 626 | return { |
| 627 | /** |
| 628 | * Pours the glass out of `origin`. Resolves true once it has landed, or false |
| 629 | * if drawBack() interrupted it. |
| 630 | */ |
| 631 | pour(origin: GlassPourSource, next: GlassPourOptions = {}): Promise<boolean> { |
| 632 | const reopening = closing && flying && !absorbed && origin === source; |
| 633 | // A draw back this interrupts never finishes. |
| 634 | finish(false); |
| 635 | const done = wait(); |
| 636 | follow.disconnect(); |
| 637 | drawingBack.delete(hurry); |
| 638 | // Any other glass still drawing back gets out of the way. A sub-menu's parent |
| 639 | // stays open, so only standalone surfaces hurry the others. |
| 640 | if (!next.submenu) for (const other of drawingBack) other(); |
| 641 | closing = false; |
| 642 | surface.removeAttribute("data-liquid-glass-closing"); |
| 643 | castShadow(); |
| 644 | // Reopened while it drew back: it pours straight back out from wherever the |
| 645 | // glass is, over its button again if it covered it. |
| 646 | if (reopening) { |
| 647 | if (covered) catcher.place(from); |
| 648 | fly(); |
| 649 | aim(standalone() ? OPEN : SUB_OPEN, toBox(menu)); |
| 650 | run(); |
| 651 | return done; |
| 652 | } |
| 653 | rest(); |
| 654 | button.show(); |
| 655 | source = origin; |
| 656 | options = next; |
| 657 | const trigger = element(); |
| 658 | // Where it was placed, not where it last covered its button. |
| 659 | if (surface.style.translate) translateInstantly(surface, null); |
| 660 | const side = trigger ? (options.side ?? sideOf(surface.getBoundingClientRect(), trigger.getBoundingClientRect())) : null; |
| 661 | // A surface moves over its button before it is measured, and the button fades |
| 662 | // as the drop leaves it. |
| 663 | covered = Boolean(options.cover && trigger && side && coverButton(surface, trigger, side)); |
| 664 | if (covered && trigger) button.hide(trigger, still() ? 0 : BUTTON_FADE); |
| 665 | // Nothing to grow from: the surface is simply there. |
| 666 | if (!measure()) { |
| 667 | finish(true); |
| 668 | return done; |
| 669 | } |
| 670 | if (covered) catcher.place(from); |
| 671 | if (still()) { |
| 672 | finish(true); |
| 673 | return done; |
| 674 | } |
| 675 | jump(toBox(from)); |
| 676 | fly(); |
| 677 | aim(standalone() ? OPEN : SUB_OPEN, toBox(menu)); |
| 678 | paint(); |
| 679 | run(); |
| 680 | return done; |
| 681 | }, |
| 682 | /** |
| 683 | * Draws the glass back into where it poured from. Resolves true once it has |
| 684 | * gone, when the surface should be hidden, or false if pour() reopened it. |
| 685 | */ |
| 686 | drawBack(): Promise<boolean> { |
| 687 | if (closing) return wait(); |
| 688 | // A pour this interrupts never lands. |
| 689 | finish(false); |
| 690 | const done = wait(); |
| 691 | if (!source) { |
| 692 | finish(true); |
| 693 | return done; |
| 694 | } |
| 695 | closing = true; |
| 696 | // Drawing back, it lets presses through to what it covered, its button among |
| 697 | // them (styles.css). |
| 698 | catcher.remove(); |
| 699 | const wasFlying = flying; |
| 700 | // With motion off, or a source that went away, it is simply gone. |
| 701 | if (still() || !measure()) { |
| 702 | rest(); |
| 703 | button.show(); |
| 704 | finish(true); |
| 705 | return done; |
| 706 | } |
| 707 | if (!wasFlying) jump(toBox(menu)); |
| 708 | surface.setAttribute("data-liquid-glass-closing", ""); |
| 709 | fly(); |
| 710 | aim(standalone() ? CLOSE : SUB_CLOSE, toBox(sink)); |
| 711 | drawingBack.add(hurry); |
| 712 | const trigger = standalone() ? element() : null; |
| 713 | if (trigger) follow.observe(trigger); |
| 714 | paint(); |
| 715 | run(); |
| 716 | return done; |
| 717 | }, |
| 718 | destroy() { |
| 719 | follow.disconnect(); |
| 720 | rest(); |
| 721 | closing = false; |
| 722 | button.show(); |
| 723 | catcher.remove(); |
| 724 | shadow?.remove(); |
| 725 | shadow = null; |
| 726 | surface.removeAttribute("data-liquid-glass-morph"); |
| 727 | surface.removeAttribute("data-liquid-glass-closing"); |
| 728 | surface.style.removeProperty("translate"); |
| 729 | finish(false); |
| 730 | }, |
| 731 | }; |
| 732 | } |
| 733 |