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