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.
| } from "./glass"; |
| 8 | import { LENS_GEOMETRY } from "./lens"; |
| 9 | import { LENS_PT, spring, STEP, stepSpring, SWELL, SWELL_PT, type Spring, type SpringConfig } from "./spring"; |
| 10 |
| 11 | /* Liquid Glass tabs, after iOS 27's tab bar. The track is a glass capsule and the |
| 12 | selected tab a well in it. One thumb travels between tabs as a single piece of |
| 13 | glass: it lifts off the track into a clear lens over the labels, stretches along |
| 14 | its path, and drops back into the track. Pressing the selected tab lifts it in |
| 15 | place, and dragging it scrubs across the tabs. Every fade is a filter |
| 16 | transition, never opacity: Chromium drops a backdrop filter for frames while its |
| 17 | element fades. */ |
| 18 |
| 19 | export type LiquidGlassTabsOptions = { |
| 20 | appearance?: LiquidGlassOptions["appearance"]; |
| 21 | orientation?: "horizontal" | "vertical" | undefined; |
| 22 | /** Selector for the tabs inside the track. Defaults to `[role="tab"]`. */ |
| 23 | tabs?: string | undefined; |
| 24 | /** |
| 25 | * Whether a tab is selected. Defaults to `aria-selected="true"`, `aria-checked="true"` |
| 26 | * (a radio group) or Radix's `data-state="active"`. The thumb follows changes to |
| 27 | * those attributes by itself; call `update()` after changes to anything else. |
| 28 | */ |
| 29 | isSelected?: ((tab: HTMLElement) => boolean) | undefined; |
| 30 | /** Selects the tab the thumb was dragged onto. Defaults to pressing and clicking it, as a pointer would. */ |
| 31 | onSelect?: ((tab: HTMLElement) => void) | undefined; |
| 32 | /** |
| 33 | * The track's own glass, or its options. By default the track is glass unless it |
| 34 | * sits on another glass surface: controls on glass get none of their own, so |
| 35 | * there the track is a recessed tint and the thumb a well in that glass. |
| 36 | */ |
| 37 | glass?: boolean | Omit<LiquidGlassOptions, "appearance" | "refraction"> | undefined; |
| 38 | /** Bend the page at the rims, where the browser supports it. On by default. */ |
| 39 | refraction?: boolean | undefined; |
| 40 | }; |
| 41 |
| 42 | export type LiquidGlassTabs = { |
| 43 | /** Measures the tabs and follows the selection again, for changes the tabs can't observe. */ |
| 44 | update(): void; |
| 45 | destroy(): void; |
| 46 | }; |
| 47 |
| 48 | type Box = { left: number; right: number; top: number; bottom: number }; |
| 49 |
| 50 | /* |
| 51 | * The selection's motion is iOS 27's tab bar, measured frame by frame from UIKit |
| 52 | * recordings and scaled by the lens's thickness, 54 pt on iOS. Lengths below are |
| 53 | * in those points. The lens's centre and size ride one spring; lifting, it grows, |
| 54 | * its well clears and it magnifies the labels beneath; it stretches along its path |
| 55 | * with its speed and squashes as it lands; and the whole bar swells while pressed. |
| 56 | */ |
| 57 | // UIKit's 0.4 s spring with a bounce of 0.15, for every distance: most of the way |
| 58 | // in 0.2 s, a slight overshoot, settled by half a second. |
| 59 | const TRAVEL: SpringConfig = { omega: (2 * Math.PI) / 0.4, zeta: 0.85 }; |
| 60 | // Lifting into a lens and dropping back in: critically damped, 0.25 s. |
| 61 | const LIFT: SpringConfig = { omega: (2 * Math.PI) / 0.25, zeta: 1 }; |
| 62 | // The well clears as the lens lifts and refills more slowly once it drops. |
| 63 | const PLATTER: SpringConfig = { omega: (2 * Math.PI) / 0.4, zeta: 1 }; |
| 64 | // Scrubbing, the lens follows the pointer on a stiffer spring. |
| 65 | const FOLLOW: SpringConfig = { omega: 32, zeta: 0.9 }; |
| 66 | // The stretch follows the lens's speed a beat behind it, and swings past rest as |
| 67 | // the lens stops. |
| 68 | const STRETCH: SpringConfig = { omega: 22, zeta: 0.55 }; |
| 69 | // Lifted, the lens grows this much on every side. iOS grows it 8 pt, but tabs sit |
| 70 | // deeper in their track than iOS's (28 of 36 px, against 54 of 62 pt), so it grows |
| 71 | // a little more to stand proud of the bar. |
| 72 | const LIFT_PT = 10; |
| 73 | // Within this of its tab, the lens drops back in, once fully lifted. |
| 74 | const RELEASE_PT = 3.5; |
| 75 | // Speed of full stretch, in points per second: about any jump of a tab or more. |
| 76 | const STRETCH_SPEED_PT = 350; |
| 77 | // Full stretch: longer along the path and thinner across it. |
| 78 | const STRETCH_ALONG = 0.114; |
| 79 | const STRETCH_ACROSS = 0.157; |
| 80 | // Landing, the lens squashes by this much for every tab width it came... |
| 81 | const SQUASH_PER_TAB = 0.04; |
| 82 | const SQUASH_MAX = 0.12; |
| 83 | // ...most of all this long after it starts dropping back in. |
| 84 | const SQUASH_PEAK = 0.18; |
| 85 | // What the lens magnifies the labels beneath it by, fully lifted. |
| 86 | const MAGNIFY = 0.1; |
| 87 | const SCRUB_THRESHOLD = 4; |
| 88 | // A press or keypress this recent makes a change of tab the viewer's own, for tabs |
| 89 | // that change only once their link has navigated. |
| 90 | const INTERACTION_MS = 800; |
| 91 | // On the track while a mouse is over it, and while it is over the selected tab. |
| 92 | const POINTER = "data-liquid-glass-tabs-pointer"; |
| 93 | const HOVER = "data-liquid-glass-tabs-hover"; |
| 94 | // While a press lights the glass under it, and while that light spreads as it fades. |
| 95 | const PRESS = "data-liquid-glass-tabs-press"; |
| 96 | const RELEASE = "data-liquid-glass-tabs-release"; |
| 97 |
| 98 | /** A pulse that peaks at 1 when `x` is 1 and dies away after it. */ |
| 99 | function pulse(x: number) { |
| 100 | return x > 0 && x < 20 ? x * x * Math.exp(2 * (1 - x)) : 0; |
| 101 | } |
| 102 |
| 103 | /** |
| 104 | * An element's box in the track's positioning frame. This is its layout box, which |
| 105 | * transforms don't move: tabs that slide into place by transform, or an opening |
| 106 | * dialog's zoom, would otherwise leave the thumb wherever they were passing, since |
| 107 | * a transform never triggers a resize to measure again. An element not laid out |
| 108 | * inside the track falls back to its visual box, with any scale on the way undone. |
| 109 | */ |
| 110 | function measure(element: HTMLElement, track: HTMLElement): Box { |
| 111 | let left = 0; |
| 112 | let top = 0; |
| 113 | let node: HTMLElement | null = element; |
| 114 | while (node && node !== track) { |
| 115 | left += node.offsetLeft; |
| 116 | top += node.offsetTop; |
| 117 | const parent: Element | null = node.offsetParent; |
| 118 | if (!(parent instanceof HTMLElement)) break; |
| 119 | // Offsets run from the parent's padding edge; the next offset from its border edge. |
| 120 | if (parent !== track) { |
| 121 | left += parent.clientLeft - parent.scrollLeft; |
| 122 | top += parent.clientTop - parent.scrollTop; |
| 123 | } |
| 124 | node = parent; |
| 125 | } |
| 126 | if (node === track) return { left, top, right: left + element.offsetWidth, bottom: top + element.offsetHeight }; |
| 127 |
| 128 | const box = element.getBoundingClientRect(); |
| 129 | const frame = track.getBoundingClientRect(); |
| 130 | const scale = track.offsetWidth ? frame.width / track.offsetWidth || 1 : 1; |
| 131 | const x = (value: number) => (value - frame.left) / scale - track.clientLeft + track.scrollLeft; |
| 132 | const y = (value: number) => (value - frame.top) / scale - track.clientTop + track.scrollTop; |
| 133 | return { left: x(box.left), right: x(box.right), top: y(box.top), bottom: y(box.bottom) }; |
| 134 | } |
| 135 |
| 136 | /** |
| 137 | * Where the thumb rests for a tab. A tab centred across the track gets equal |
| 138 | * whole-pixel gaps on both sides; a half-pixel height would render a pixel low. |
| 139 | */ |
| 140 | function restBox(element: HTMLElement, track: HTMLElement, vertical: boolean): Box { |
| 141 | const box = measure(element, track); |
| 142 | const round = (value: number) => Math.round(value); |
| 143 | const center = (from: number, to: number, extent: number) => { |
| 144 | if (Math.abs((from + to) / 2 - extent / 2) >= 1) return [round(from), round(to)] as const; |
| 145 | const gap = Math.floor((extent - (to - from)) / 2); |
| 146 | return [gap, extent - gap] as const; |
| 147 | }; |
| 148 | if (vertical) { |
| 149 | const [left, right] = center(box.left, box.right, track.clientWidth); |
| 150 | return { left, right, top: round(box.top), bottom: round(box.bottom) }; |
| 151 | } |
| 152 | const [top, bottom] = center(box.top, box.bottom, track.clientHeight); |
| 153 | return { left: round(box.left), right: round(box.right), top, bottom }; |
| 154 | } |
| 155 |
| 156 | function sameBox(a: Box, b: Box) { |
| 157 | return ( |
| 158 | Math.abs(a.left - b.left) < 0.5 && |
| 159 | Math.abs(a.right - b.right) < 0.5 && |
| 160 | Math.abs(a.top - b.top) < 0.5 && |
| 161 | Math.abs(a.bottom - b.bottom) < 0.5 |
| 162 | ); |
| 163 | } |
| 164 |
| 165 | function defaultIsSelected(tab: HTMLElement) { |
| 166 | return ( |
| 167 | tab.getAttribute("aria-selected") === "true" || |
| 168 | tab.getAttribute("aria-checked") === "true" || |
| 169 | tab.dataset.state === "active" |
| 170 | ); |
| 171 | } |
| 172 |
| 173 | /** Selects a tab as a click would: Radix selects on mousedown, links and click handlers on click. */ |
| 174 | function defaultSelect(tab: HTMLElement) { |
| 175 | tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, view: window })); |
| 176 | tab.click(); |
| 177 | tab.focus({ preventScroll: true }); |
| 178 | } |
| 179 |
| 180 | function span(className: string) { |
| 181 | const element = document.createElement("span"); |
| 182 | element.className = className; |
| 183 | element.setAttribute("aria-hidden", "true"); |
| 184 | return element; |
| 185 | } |
| 186 |
| 187 | /** |
| 188 | * Turns a row of tabs into Liquid Glass tabs. The track is the tabs' positioned, |
| 189 | * rounded container; the tabs and their selection stay yours, and the thumb follows |
| 190 | * whichever is selected. |
| 191 | * |
| 192 | * ```ts |
| 193 | * const tabs = attachLiquidGlassTabs(document.querySelector('[role="tablist"]')!); |
| 194 | * ``` |
| 195 | */ |
| 196 | export function attachLiquidGlassTabs(track: HTMLElement, options: LiquidGlassTabsOptions = {}): LiquidGlassTabs { |
| 197 | const selector = options.tabs ?? '[role="tab"]'; |
| 198 | const isSelected = options.isSelected ?? defaultIsSelected; |
| 199 | const onSelect = options.onSelect ?? defaultSelect; |
| 200 | const vertical = options.orientation === "vertical"; |
| 201 | const reduced = matchMedia("(prefers-reduced-motion: reduce)"); |
| 202 |
| 203 | // State lives in attributes of its own, not classes: a framework re-rendering the |
| 204 | // track or its tabs owns their class lists, and libraries set their own data-*. |
| 205 | track.setAttribute("data-liquid-glass-tabs", vertical ? "vertical" : "horizontal"); |
| 206 | const onGlass = |
| 207 | options.glass === undefined |
| 208 | ? Boolean(track.parentElement?.closest(":has(> .liquid-glass)")) |
| 209 | : options.glass === false; |
| 210 | track.toggleAttribute("data-liquid-glass-tabs-on-glass", onGlass); |
| 211 | const paintAppearance = () => { |
| 212 | track.setAttribute("data-liquid-glass-tabs-appearance", resolveAppearance(options.appearance)); |
| 213 | }; |
| 214 | paintAppearance(); |
| 215 | const unwatchAppearance = watchAppearance(paintAppearance); |
| 216 |
| 217 | const trackGlass: LiquidGlass | null = onGlass |
| 218 | ? null |
| 219 | : createLiquidGlass(track, { |
| 220 | ...(typeof options.glass === "object" ? options.glass : {}), |
| 221 | appearance: options.appearance, |
| 222 | refraction: options.refraction, |
| 223 | }); |
| 224 | const light = span("liquid-glass-tabs-light"); |
| 225 | const thumb = span("liquid-glass-tabs-thumb"); |
| 226 | const lens = span("liquid-glass-tabs-lens"); |
| 227 | thumb.append(span("liquid-glass-tabs-well"), lens); |
| 228 | if (trackGlass) trackGlass.element.after(light, thumb); |
| 229 | else track.prepend(light, thumb); |
| 230 | // A rim for a lens a tab high. Four refraction samples keep the labels it bends |
| 231 | // from stair-stepping, and it magnifies them while lifted. |
| 232 | const lensGlass = createLiquidGlass(lens, { |
| 233 | appearance: options.appearance, |
| 234 | variant: "lens", |
| 235 | geometry: LENS_GEOMETRY, |
| 236 | refraction: options.refraction, |
| 237 | samples: 4, |
| 238 | }); |
| 239 |
| 240 | const allTabs = () => |
| 241 | Array.from(track.querySelectorAll<HTMLElement>(selector)).filter( |
| 242 | tab => !tab.hasAttribute("disabled") && !tab.hasAttribute("data-disabled") && tab.getAttribute("aria-disabled") !== "true", |
| 243 | ); |
| 244 |
| 245 | const state = { |
| 246 | selected: null as HTMLElement | null, |
| 247 | /** The selected tab's box, where the thumb comes to rest. */ |
| 248 | rest: null as Box | null, |
| 249 | pressed: false, |
| 250 | /** When a key or the pointer was last let go in the track. */ |
| 251 | interacted: -Infinity, |
| 252 | scrub: null as null | { pointerId: number; startX: number; active: boolean; min: number; max: number }, |
| 253 | }; |
| 254 |
| 255 | // ── Motion ── |
| 256 | const sim = { |
| 257 | // The lens's centre and its size before lifting and deforming. |
| 258 | x: spring(), |
| 259 | y: spring(), |
| 260 | w: spring(), |
| 261 | h: spring(), |
| 262 | lift: spring(), |
| 263 | platter: spring(1), |
| 264 | stretch: spring(), |
| 265 | swell: spring(), |
| 266 | /** The box the lens is headed for. */ |
| 267 | target: null as Box | null, |
| 268 | /** While scrubbing, the centre the lens follows. */ |
| 269 | follow: null as number | null, |
| 270 | lifting: false, |
| 271 | swelling: false, |
| 272 | /** 1 or -1 along the track, the way the lens is going. */ |
| 273 | direction: 1, |
| 274 | /** How far the current journey goes, in widths of its tab. */ |
| 275 | tabs: 0, |
| 276 | squash: 0, |
| 277 | squashFrom: -Infinity, |
| 278 | frame: 0, |
| 279 | last: 0, |
| 280 | }; |
| 281 | // Pixels to iOS's points, by the resting lens's thickness. |
| 282 | const pointsOf = (box: Box) => Math.min(box.right - box.left, box.bottom - box.top) / LENS_PT || 1; |
| 283 |
| 284 | const write = (now: number, moving = true) => { |
| 285 | if (!sim.target) return; |
| 286 | const pt = pointsOf(sim.target); |
| 287 | const lift = Math.min(Math.max(sim.lift.value, 0), 1); |
| 288 | const w = sim.w.value + 2 * LIFT_PT * pt * lift; |
| 289 | const h = sim.h.value + 2 * LIFT_PT * pt * lift; |
| 290 | const stretch = sim.stretch.value; |
| 291 | const squash = sim.squash * pulse((now - sim.squashFrom) / 1000 / SQUASH_PEAK); |
| 292 | const along = 1 + STRETCH_ALONG * stretch - squash; |
| 293 | const across = 1 - STRETCH_ACROSS * stretch + 1.2 * squash; |
| 294 | const sx = vertical ? across : along; |
| 295 | const sy = vertical ? along : across; |
| 296 | // The stretch reaches ahead: the leading end runs on while the trailing one holds back. |
| 297 | const lead = (sim.direction * STRETCH_ALONG * Math.max(stretch, 0) * (vertical ? h : w)) / 2; |
| 298 | const cx = sim.x.value + (vertical ? 0 : lead); |
| 299 | const cy = sim.y.value + (vertical ? lead : 0); |
| 300 | thumb.style.transform = `translate(${cx - (w * sx) / 2}px, ${cy - (h * sy) / 2}px)`; |
| 301 | thumb.style.width = `${w * sx}px`; |
| 302 | thumb.style.height = `${h * sy}px`; |
| 303 | // styles.css fades the well, the shadow and the lens's light by these. |
| 304 | thumb.style.setProperty("--liquid-glass-tabs-lift", lift.toFixed(3)); |
| 305 | thumb.style.setProperty("--liquid-glass-tabs-platter", Math.min(Math.max(sim.platter.value, 0), 1).toFixed(3)); |
| 306 | thumb.toggleAttribute("data-moving", moving); |
| 307 | thumb.toggleAttribute("data-lifted", sim.lifting); |
| 308 | // Over the labels while it is a lens, so it can bend them; under them at rest. |
| 309 | thumb.toggleAttribute("data-raised", sim.lifting || lift > 0.02); |
| 310 | lensGlass.magnify(1 + MAGNIFY * lift); |
| 311 | // The whole bar, glass and labels, swells about its centre, by points of its length. |
| 312 | const swell = sim.swell.value; |
| 313 | const length = Math.max(track.offsetWidth, track.offsetHeight); |
| 314 | track.style.scale = Math.abs(swell) > 1e-4 ? (1 + (swell * SWELL_PT * pt) / length).toFixed(5) : ""; |
| 315 | }; |
| 316 |
| 317 | const near = (value: Spring, to: number) => Math.abs(value.value - to) < 0.05 && Math.abs(value.velocity) < 2; |
| 318 | const settled = (box: Box, now: number) => |
| 319 | !sim.lifting && |
| 320 | !sim.swelling && |
| 321 | sim.follow === null && |
| 322 | !state.pressed && |
| 323 | near(sim.x, (box.left + box.right) / 2) && |
| 324 | near(sim.y, (box.top + box.bottom) / 2) && |
| 325 | near(sim.w, box.right - box.left) && |
| 326 | near(sim.h, box.bottom - box.top) && |
| 327 | sim.lift.value < 0.002 && |
| 328 | sim.platter.value > 0.998 && |
| 329 | Math.abs(sim.stretch.value) < 0.002 && |
| 330 | Math.abs(sim.swell.value) < 0.0005 && |
| 331 | Math.abs(sim.swell.velocity) < 0.01 && |
| 332 | (now - sim.squashFrom) / 1000 > SQUASH_PEAK * 4; |
| 333 |
| 334 | const stop = () => { |
| 335 | if (sim.frame) cancelAnimationFrame(sim.frame); |
| 336 | sim.frame = 0; |
| 337 | }; |
| 338 |
| 339 | const snap = (box: Box) => { |
| 340 | stop(); |
| 341 | sim.target = box; |
| 342 | sim.x = spring((box.left + box.right) / 2); |
| 343 | sim.y = spring((box.top + box.bottom) / 2); |
| 344 | sim.w = spring(box.right - box.left); |
| 345 | sim.h = spring(box.bottom - box.top); |
| 346 | sim.lift = spring(); |
| 347 | sim.platter = spring(1); |
| 348 | sim.stretch = spring(); |
| 349 | sim.swell = spring(); |
| 350 | sim.lifting = false; |
| 351 | sim.swelling = false; |
| 352 | sim.follow = null; |
| 353 | sim.squash = 0; |
| 354 | write(performance.now(), false); |
| 355 | }; |
| 356 |
| 357 | const tick = (now: number) => { |
| 358 | sim.frame = 0; |
| 359 | const box = sim.target; |
| 360 | if (!box) return; |
| 361 | const pt = pointsOf(box); |
| 362 | const tx = (box.left + box.right) / 2; |
| 363 | const ty = (box.top + box.bottom) / 2; |
| 364 | // A long gap (a background tab) shouldn't fling the lens. The first frame's |
| 365 | // timestamp can precede the performance.now() it was scheduled at. |
| 366 | let elapsed = Math.min(Math.max(now - sim.last, 0) / 1000, 1 / 20); |
| 367 | sim.last = now; |
| 368 | while (elapsed > 1e-6) { |
| 369 | const dt = Math.min(STEP, elapsed); |
| 370 | elapsed -= dt; |
| 371 | const following = sim.follow; |
| 372 | stepSpring(sim.x, following ?? tx, following === null ? TRAVEL : FOLLOW, dt); |
| 373 | stepSpring(sim.y, ty, TRAVEL, dt); |
| 374 | stepSpring(sim.w, box.right - box.left, TRAVEL, dt); |
| 375 | stepSpring(sim.h, box.bottom - box.top, TRAVEL, dt); |
| 376 | stepSpring(sim.lift, sim.lifting ? 1 : 0, LIFT, dt); |
| 377 | if (sim.lifting) { |
| 378 | sim.platter.value = 1 - sim.lift.value; |
| 379 | sim.platter.velocity = -sim.lift.velocity; |
| 380 | } else { |
| 381 | stepSpring(sim.platter, 1, PLATTER, dt); |
| 382 | } |
| 383 | const speed = Math.abs(vertical ? sim.y.velocity : sim.x.velocity) / pt; |
| 384 | stepSpring(sim.stretch, Math.min(speed / STRETCH_SPEED_PT, 1), STRETCH, dt); |
| 385 | stepSpring(sim.swell, sim.swelling ? 1 : 0, SWELL, dt); |
| 386 | } |
| 387 | const velocity = vertical ? sim.y.velocity : sim.x.velocity; |
| 388 | if (Math.abs(velocity) > 20 * pt) sim.direction = Math.sign(velocity); |
| 389 | // Fully lifted and nearly there, the lens drops back in and squashes as it lands. |
| 390 | const remaining = Math.hypot(tx - sim.x.value, ty - sim.y.value); |
| 391 | if (sim.lifting && !state.pressed && sim.follow === null && remaining < RELEASE_PT * pt && sim.lift.value > 0.975) { |
| 392 | sim.lifting = false; |
| 393 | sim.squash = Math.min(SQUASH_PER_TAB * sim.tabs, SQUASH_MAX); |
| 394 | sim.squashFrom = now; |
| 395 | } |
| 396 | if (settled(box, now)) return snap(box); |
| 397 | write(now); |
| 398 | sim.frame = requestAnimationFrame(tick); |
| 399 | }; |
| 400 |
| 401 | const run = () => { |
| 402 | if (sim.frame || !sim.target) return; |
| 403 | sim.last = performance.now(); |
| 404 | sim.frame = requestAnimationFrame(tick); |
| 405 | }; |
| 406 |
| 407 | /** Heads for `box`. A change of tab the viewer made lifts the lens on its way, as a tap does. */ |
| 408 | const travel = (box: Box, lift: boolean) => { |
| 409 | if (reduced.matches) return snap(box); |
| 410 | const from = vertical ? sim.y.value : sim.x.value; |
| 411 | const to = vertical ? (box.top + box.bottom) / 2 : (box.left + box.right) / 2; |
| 412 | const size = vertical ? box.bottom - box.top : box.right - box.left; |
| 413 | sim.target = box; |
| 414 | sim.tabs = Math.abs(to - from) / Math.max(size, 1); |
| 415 | if (Math.abs(to - from) > 0.5) sim.direction = Math.sign(to - from); |
| 416 | if (lift) sim.lifting = true; |
| 417 | run(); |
| 418 | }; |
| 419 |
| 420 | /** Lifts the lens where it is, for a press on the selected tab. */ |
| 421 | const press = () => { |
| 422 | if (reduced.matches || !sim.target) return; |
| 423 | sim.lifting = true; |
| 424 | sim.tabs = 0; |
| 425 | run(); |
| 426 | }; |
| 427 |
| 428 | /** Scrubbing, keeps the lens after `center`; null hands it back to its tab. */ |
| 429 | const followPointer = (center: number | null) => { |
| 430 | sim.follow = center; |
| 431 | run(); |
| 432 | }; |
| 433 |
| 434 | const swell = (swelling: boolean) => { |
| 435 | if (reduced.matches) return; |
| 436 | sim.swelling = swelling; |
| 437 | run(); |
| 438 | }; |
| 439 |
| 440 | /** A resized tab: mid-motion the lens heads for its new box, at rest it moves there at once. */ |
| 441 | const retarget = (box: Box) => { |
| 442 | if (sim.frame || state.pressed) sim.target = box; |
| 443 | else snap(box); |
| 444 | }; |
| 445 |
| 446 | // ── Selection ── |
| 447 | const follow = () => { |
| 448 | const tabs = allTabs(); |
| 449 | const selected = tabs.find(isSelected) ?? null; |
| 450 | for (const tab of tabs) { |
| 451 | tab.setAttribute("data-liquid-glass-tab", ""); |
| 452 | tab.toggleAttribute("data-liquid-glass-tab-selected", tab === selected); |
| 453 | } |
| 454 | const previous = state.selected; |
| 455 | state.selected = selected; |
| 456 | if (!selected) { |
| 457 | state.rest = null; |
| 458 | thumb.toggleAttribute("data-shown", false); |
| 459 | return; |
| 460 | } |
| 461 | const box = restBox(selected, track, vertical); |
| 462 | // In a horizontal track the thumb's corners follow the track's, inset by the gap |
| 463 | // between them, as iOS nests its glass. A capsule track holds a capsule thumb, |
| 464 | // and a capsule stays a capsule as the lens grows. |
| 465 | const trackRadius = Math.min(parseFloat(getComputedStyle(track).borderTopLeftRadius) || 0, track.offsetHeight / 2); |
| 466 | const concentric = vertical ? 0 : trackRadius - track.clientTop - box.top; |
| 467 | const radius = Math.max(parseFloat(getComputedStyle(selected).borderTopLeftRadius) || 0, concentric); |
| 468 | thumb.style.borderRadius = radius >= (box.bottom - box.top) / 2 - 0.5 ? "9999px" : `${radius}px`; |
| 469 | const appearing = !state.rest; |
| 470 | state.rest = box; |
| 471 | thumb.toggleAttribute("data-shown", true); |
| 472 | // A tab chosen with the pointer is under it now. |
| 473 | track.toggleAttribute(HOVER, track.hasAttribute(POINTER) && selected.matches(":hover")); |
| 474 | if (appearing) snap(box); |
| 475 | // A tab the viewer picked lifts the lens on its way, as a tap does on iOS; one |
| 476 | // chosen for them (a route, your own state) slides flat. |
| 477 | else if (previous !== selected) travel(box, state.pressed || performance.now() - state.interacted < INTERACTION_MS); |
| 478 | else retarget(box); |
| 479 | }; |
| 480 |
| 481 | // Tabs resize as labels change, fonts load and the track reflows. |
| 482 | const resize = new ResizeObserver(() => { |
| 483 | const selected = state.selected; |
| 484 | if (!selected) return; |
| 485 | const box = restBox(selected, track, vertical); |
| 486 | const previous = state.rest; |
| 487 | state.rest = box; |
| 488 | if (state.scrub?.active) return; |
| 489 | if (sim.frame) retarget(box); |
| 490 | else if (!previous || !sameBox(previous, box)) snap(box); |
| 491 | }); |
| 492 | const observeSizes = () => { |
| 493 | resize.disconnect(); |
| 494 | resize.observe(track); |
| 495 | for (const tab of allTabs()) resize.observe(tab); |
| 496 | }; |
| 497 | const mutations = new MutationObserver(records => { |
| 498 | if (records.some(record => record.type === "childList")) observeSizes(); |
| 499 | follow(); |
| 500 | }); |
| 501 | mutations.observe(track, { |
| 502 | subtree: true, |
| 503 | childList: true, |
| 504 | attributes: true, |
| 505 | attributeFilter: ["aria-selected", "aria-checked", "data-state", "disabled", "data-disabled", "aria-disabled"], |
| 506 | }); |
| 507 |
| 508 | // ── Pointer: light, press and scrubbing ── |
| 509 | const selectedTab = () => state.selected; |
| 510 | // The track's own frame, with its swell undone. |
| 511 | const toTrack = (clientX: number, clientY = 0) => { |
| 512 | const frame = track.getBoundingClientRect(); |
| 513 | const scale = track.offsetWidth ? frame.width / track.offsetWidth || 1 : 1; |
| 514 | return { |
| 515 | x: (clientX - frame.left) / scale - track.clientLeft + track.scrollLeft, |
| 516 | y: (clientY - frame.top) / scale - track.clientTop + track.scrollTop, |
| 517 | left: (clientX - frame.left) / scale, |
| 518 | top: (clientY - frame.top) / scale, |
| 519 | }; |
| 520 | }; |
| 521 |
| 522 | // The light follows the pointer through its own properties: set on the track they |
| 523 | // would be inherited, restyling every tab on every move. |
| 524 | const placeLight = (event: PointerEvent) => { |
| 525 | const { left, top } = toTrack(event.clientX, event.clientY); |
| 526 | light.style.setProperty("--liquid-glass-tabs-x", `${left}px`); |
| 527 | light.style.setProperty("--liquid-glass-tabs-y", `${top}px`); |
| 528 | }; |
| 529 | // A press brightens the glass under it; letting go, the light spreads across the |
| 530 | // bar as it fades. |
| 531 | let spreading = 0; |
| 532 | const setLight = (phase: "press" | "release" | null) => { |
| 533 | window.clearTimeout(spreading); |
| 534 | track.toggleAttribute(PRESS, phase === "press"); |
| 535 | track.toggleAttribute(RELEASE, phase === "release"); |
| 536 | if (phase === "release") spreading = window.setTimeout(() => setLight(null), 500); |
| 537 | }; |
| 538 |
| 539 | const onPointerEnter = (event: PointerEvent) => { |
| 540 | if (event.pointerType !== "touch") track.toggleAttribute(POINTER, true); |
| 541 | }; |
| 542 | const onPointerLeave = () => { |
| 543 | track.removeAttribute(POINTER); |
| 544 | track.removeAttribute(HOVER); |
| 545 | }; |
| 546 | const onKeyDown = () => { |
| 547 | state.interacted = performance.now(); |
| 548 | }; |
| 549 |
| 550 | const onPointerMove = (event: PointerEvent) => { |
| 551 | if (event.pointerType !== "touch" || state.pressed) { |
| 552 | placeLight(event); |
| 553 | // Brighten the well under the pointer. An attribute, not a `:has()` rule, so |
| 554 | // changing tabs doesn't restyle the whole track. |
| 555 | const overSelected = event.target instanceof Node && Boolean(selectedTab()?.contains(event.target)); |
| 556 | if (track.hasAttribute(HOVER) !== overSelected) { |
| 557 | track.toggleAttribute(HOVER, overSelected); |
| 558 | } |
| 559 | } |
| 560 | const scrub = state.scrub; |
| 561 | const rest = state.rest; |
| 562 | if (!scrub || !rest || event.pointerId !== scrub.pointerId) return; |
| 563 | if (!scrub.active) { |
| 564 | if (Math.abs(event.clientX - scrub.startX) < SCRUB_THRESHOLD) return; |
| 565 | scrub.active = true; |
| 566 | track.setPointerCapture(event.pointerId); |
| 567 | } |
| 568 | const half = (rest.right - rest.left) / 2; |
| 569 | followPointer(Math.min(Math.max(toTrack(event.clientX).x, scrub.min + half), scrub.max - half)); |
| 570 | }; |
| 571 |
| 572 | const onRelease = (event: PointerEvent) => { |
| 573 | window.removeEventListener("pointerup", onRelease); |
| 574 | window.removeEventListener("pointercancel", onRelease); |
| 575 | state.pressed = false; |
| 576 | state.interacted = performance.now(); |
| 577 | setLight("release"); |
| 578 | swell(false); |
| 579 | const scrub = state.scrub; |
| 580 | state.scrub = null; |
| 581 | if (!scrub?.active) { |
| 582 | // The lens drops back in once it has fully lifted and arrived. |
| 583 | run(); |
| 584 | return; |
| 585 | } |
| 586 | followPointer(null); |
| 587 | if (track.hasPointerCapture(scrub.pointerId)) track.releasePointerCapture(scrub.pointerId); |
| 588 | // Land on the tab under the pointer, as iOS does, or the end tab past either |
| 589 | // end. Not the tab nearest the lens: the lens keeps the selected tab's width |
| 590 | // inside the track, so beside narrower tabs its centre never reaches the end ones. |
| 591 | const pointer = toTrack(event.clientX).x; |
| 592 | let nearest: HTMLElement | null = null; |
| 593 | let nearestGap = Infinity; |
| 594 | let nearestOffset = Infinity; |
| 595 | if (event.type === "pointerup") { |
| 596 | for (const tab of allTabs()) { |
| 597 | const box = measure(tab, track); |
| 598 | // How far outside the tab the pointer is (0 over it), then from its centre. |
| 599 | const gap = Math.max(box.left - pointer, 0, pointer - box.right); |
| 600 | const offset = Math.abs((box.left + box.right) / 2 - pointer); |
| 601 | if (gap < nearestGap || (gap === nearestGap && offset < nearestOffset)) { |
| 602 | nearestGap = gap; |
| 603 | nearestOffset = offset; |
| 604 | nearest = tab; |
| 605 | } |
| 606 | } |
| 607 | } |
| 608 | const before = state.selected; |
| 609 | if (nearest && nearest !== selectedTab()) { |
| 610 | onSelect(nearest); |
| 611 | // Tabs that don't change (or change later, after navigating) send the lens home. |
| 612 | requestAnimationFrame(() => { |
| 613 | if (state.selected === before && state.rest) travel(state.rest, true); |
| 614 | }); |
| 615 | } else if (state.rest) { |
| 616 | travel(state.rest, true); |
| 617 | } |
| 618 | }; |
| 619 |
| 620 | const onPointerDown = (event: PointerEvent) => { |
| 621 | if (event.button !== 0 || !state.rest || reduced.matches) return; |
| 622 | const tab = event.target instanceof Element ? event.target.closest<HTMLElement>(selector) : null; |
| 623 | if (!tab || !allTabs().includes(tab)) return; |
| 624 | state.pressed = true; |
| 625 | window.addEventListener("pointerup", onRelease); |
| 626 | window.addEventListener("pointercancel", onRelease); |
| 627 | placeLight(event); |
| 628 | setLight("press"); |
| 629 | swell(true); |
| 630 | // Another tab: selecting it sends the lens there. |
| 631 | if (tab !== selectedTab()) return; |
| 632 | if (!vertical) { |
| 633 | const boxes = allTabs().map(item => measure(item, track)); |
| 634 | state.scrub = { |
| 635 | pointerId: event.pointerId, |
| 636 | startX: event.clientX, |
| 637 | active: false, |
| 638 | min: Math.min(...boxes.map(box => box.left)), |
| 639 | max: Math.max(...boxes.map(box => box.right)), |
| 640 | }; |
| 641 | } |
| 642 | press(); |
| 643 | }; |
| 644 |
| 645 | // Links would start a native drag instead of scrubbing the thumb. |
| 646 | const onDragStart = (event: DragEvent) => { |
| 647 | if (event.target instanceof Element && event.target.closest(selector)) event.preventDefault(); |
| 648 | }; |
| 649 |
| 650 | track.addEventListener("pointerenter", onPointerEnter); |
| 651 | track.addEventListener("pointerleave", onPointerLeave); |
| 652 | track.addEventListener("pointermove", onPointerMove); |
| 653 | track.addEventListener("pointerdown", onPointerDown); |
| 654 | track.addEventListener("keydown", onKeyDown); |
| 655 | track.addEventListener("dragstart", onDragStart); |
| 656 | observeSizes(); |
| 657 | follow(); |
| 658 |
| 659 | return { |
| 660 | update() { |
| 661 | observeSizes(); |
| 662 | follow(); |
| 663 | }, |
| 664 | destroy() { |
| 665 | stop(); |
| 666 | window.clearTimeout(spreading); |
| 667 | mutations.disconnect(); |
| 668 | resize.disconnect(); |
| 669 | unwatchAppearance(); |
| 670 | track.removeEventListener("pointerenter", onPointerEnter); |
| 671 | track.removeEventListener("pointerleave", onPointerLeave); |
| 672 | track.removeEventListener("pointermove", onPointerMove); |
| 673 | track.removeEventListener("pointerdown", onPointerDown); |
| 674 | track.removeEventListener("keydown", onKeyDown); |
| 675 | track.removeEventListener("dragstart", onDragStart); |
| 676 | window.removeEventListener("pointerup", onRelease); |
| 677 | window.removeEventListener("pointercancel", onRelease); |
| 678 | track.style.scale = ""; |
| 679 | lensGlass.destroy(); |
| 680 | trackGlass?.destroy(); |
| 681 | light.remove(); |
| 682 | thumb.remove(); |
| 683 | for (const tab of allTabs()) { |
| 684 | tab.removeAttribute("data-liquid-glass-tab"); |
| 685 | tab.removeAttribute("data-liquid-glass-tab-selected"); |
| 686 | } |
| 687 | for (const attribute of [ |
| 688 | "data-liquid-glass-tabs", |
| 689 | "data-liquid-glass-tabs-appearance", |
| 690 | "data-liquid-glass-tabs-on-glass", |
| 691 | POINTER, |
| 692 | HOVER, |
| 693 | PRESS, |
| 694 | RELEASE, |
| 695 | ]) { |
| 696 | track.removeAttribute(attribute); |
| 697 | } |
| 698 | }, |
| 699 | }; |
| 700 | } |
| 701 |