import { createLiquidGlass, resolveAppearance, watchAppearance, type LiquidGlass, type LiquidGlassOptions, } from "./glass"; import { LENS_GEOMETRY } from "./lens"; import { spring, STEP, stepSpring, type Spring, type SpringConfig } from "./spring"; /* Liquid Glass tabs, after iOS 27's tab bar. The track is a glass capsule and the selected tab a well in it. One thumb travels between tabs as a single piece of glass: it lifts off the track into a clear lens over the labels, stretches along its path, and drops back into the track. Pressing the selected tab lifts it in place, and dragging it scrubs across the tabs. Every fade is a filter transition, never opacity: Chromium drops a backdrop filter for frames while its element fades. */ export type LiquidGlassTabsOptions = { appearance?: LiquidGlassOptions["appearance"]; orientation?: "horizontal" | "vertical" | undefined; /** Selector for the tabs inside the track. Defaults to `[role="tab"]`. */ tabs?: string | undefined; /** * Whether a tab is selected. Defaults to `aria-selected="true"`, `aria-checked="true"` * (a radio group) or Radix's `data-state="active"`. The thumb follows changes to * those attributes by itself; call `update()` after changes to anything else. */ isSelected?: ((tab: HTMLElement) => boolean) | undefined; /** Selects the tab the thumb was dragged onto. Defaults to pressing and clicking it, as a pointer would. */ onSelect?: ((tab: HTMLElement) => void) | undefined; /** * The track's own glass, or its options. By default the track is glass unless it * sits on another glass surface: controls on glass get none of their own, so * there the track is a recessed tint and the thumb a well in that glass. */ glass?: boolean | Omit | undefined; /** Bend the page at the rims, where the browser supports it. On by default. */ refraction?: boolean | undefined; }; export type LiquidGlassTabs = { /** Measures the tabs and follows the selection again, for changes the tabs can't observe. */ update(): void; destroy(): void; }; type Box = { left: number; right: number; top: number; bottom: number }; /* * The selection's motion is iOS 27's tab bar, measured frame by frame from UIKit * recordings and scaled by the lens's thickness, 54 pt on iOS. Lengths below are * in those points. The lens's centre and size ride one spring; lifting, it grows, * its well clears and it magnifies the labels beneath; it stretches along its path * with its speed and squashes as it lands; and the whole bar swells while pressed. */ // UIKit's 0.4 s spring with a bounce of 0.15, for every distance: most of the way // in 0.2 s, a slight overshoot, settled by half a second. const TRAVEL: SpringConfig = { omega: (2 * Math.PI) / 0.4, zeta: 0.85 }; // Lifting into a lens and dropping back in: critically damped, 0.25 s. const LIFT: SpringConfig = { omega: (2 * Math.PI) / 0.25, zeta: 1 }; // The well clears as the lens lifts and refills more slowly once it drops. const PLATTER: SpringConfig = { omega: (2 * Math.PI) / 0.4, zeta: 1 }; // Scrubbing, the lens follows the pointer on a stiffer spring. const FOLLOW: SpringConfig = { omega: 32, zeta: 0.9 }; // The stretch follows the lens's speed a beat behind it, and swings past rest as // the lens stops. const STRETCH: SpringConfig = { omega: 22, zeta: 0.55 }; // Pressed, the bar swells on a bouncy spring, and springs back on release. const SWELL: SpringConfig = { omega: 17.6, zeta: 0.59 }; const LENS_PT = 54; // Lifted, the lens grows this much on every side. iOS grows it 8 pt, but tabs sit // deeper in their track than iOS's (28 of 36 px, against 54 of 62 pt), so it grows // a little more to stand proud of the bar. const LIFT_PT = 10; // Within this of its tab, the lens drops back in, once fully lifted. const RELEASE_PT = 3.5; // Swollen, the bar is this much wider, and as much taller in proportion. const SWELL_PT = 14.6; // Speed of full stretch, in points per second: about any jump of a tab or more. const STRETCH_SPEED_PT = 350; // Full stretch: longer along the path and thinner across it. const STRETCH_ALONG = 0.114; const STRETCH_ACROSS = 0.157; // Landing, the lens squashes by this much for every tab width it came... const SQUASH_PER_TAB = 0.04; const SQUASH_MAX = 0.12; // ...most of all this long after it starts dropping back in. const SQUASH_PEAK = 0.18; // What the lens magnifies the labels beneath it by, fully lifted. const MAGNIFY = 0.1; const SCRUB_THRESHOLD = 4; // A press or keypress this recent makes a change of tab the viewer's own, for tabs // that change only once their link has navigated. const INTERACTION_MS = 800; // On the track while a mouse is over it, and while it is over the selected tab. const POINTER = "data-liquid-glass-tabs-pointer"; const HOVER = "data-liquid-glass-tabs-hover"; // While a press lights the glass under it, and while that light spreads as it fades. const PRESS = "data-liquid-glass-tabs-press"; const RELEASE = "data-liquid-glass-tabs-release"; /** A pulse that peaks at 1 when `x` is 1 and dies away after it. */ function pulse(x: number) { return x > 0 && x < 20 ? x * x * Math.exp(2 * (1 - x)) : 0; } /** * An element's box in the track's positioning frame. This is its layout box, which * transforms don't move: tabs that slide into place by transform, or an opening * dialog's zoom, would otherwise leave the thumb wherever they were passing, since * a transform never triggers a resize to measure again. An element not laid out * inside the track falls back to its visual box, with any scale on the way undone. */ function measure(element: HTMLElement, track: HTMLElement): Box { let left = 0; let top = 0; let node: HTMLElement | null = element; while (node && node !== track) { left += node.offsetLeft; top += node.offsetTop; const parent: Element | null = node.offsetParent; if (!(parent instanceof HTMLElement)) break; // Offsets run from the parent's padding edge; the next offset from its border edge. if (parent !== track) { left += parent.clientLeft - parent.scrollLeft; top += parent.clientTop - parent.scrollTop; } node = parent; } if (node === track) return { left, top, right: left + element.offsetWidth, bottom: top + element.offsetHeight }; const box = element.getBoundingClientRect(); const frame = track.getBoundingClientRect(); const scale = track.offsetWidth ? frame.width / track.offsetWidth || 1 : 1; const x = (value: number) => (value - frame.left) / scale - track.clientLeft + track.scrollLeft; const y = (value: number) => (value - frame.top) / scale - track.clientTop + track.scrollTop; return { left: x(box.left), right: x(box.right), top: y(box.top), bottom: y(box.bottom) }; } /** * Where the thumb rests for a tab. A tab centred across the track gets equal * whole-pixel gaps on both sides; a half-pixel height would render a pixel low. */ function restBox(element: HTMLElement, track: HTMLElement, vertical: boolean): Box { const box = measure(element, track); const round = (value: number) => Math.round(value); const center = (from: number, to: number, extent: number) => { if (Math.abs((from + to) / 2 - extent / 2) >= 1) return [round(from), round(to)] as const; const gap = Math.floor((extent - (to - from)) / 2); return [gap, extent - gap] as const; }; if (vertical) { const [left, right] = center(box.left, box.right, track.clientWidth); return { left, right, top: round(box.top), bottom: round(box.bottom) }; } const [top, bottom] = center(box.top, box.bottom, track.clientHeight); return { left: round(box.left), right: round(box.right), top, bottom }; } function sameBox(a: Box, b: Box) { return ( Math.abs(a.left - b.left) < 0.5 && Math.abs(a.right - b.right) < 0.5 && Math.abs(a.top - b.top) < 0.5 && Math.abs(a.bottom - b.bottom) < 0.5 ); } function defaultIsSelected(tab: HTMLElement) { return ( tab.getAttribute("aria-selected") === "true" || tab.getAttribute("aria-checked") === "true" || tab.dataset.state === "active" ); } /** Selects a tab as a click would: Radix selects on mousedown, links and click handlers on click. */ function defaultSelect(tab: HTMLElement) { tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, view: window })); tab.click(); tab.focus({ preventScroll: true }); } function span(className: string) { const element = document.createElement("span"); element.className = className; element.setAttribute("aria-hidden", "true"); return element; } /** * Turns a row of tabs into Liquid Glass tabs. The track is the tabs' positioned, * rounded container; the tabs and their selection stay yours, and the thumb follows * whichever is selected. * * ```ts * const tabs = attachLiquidGlassTabs(document.querySelector('[role="tablist"]')!); * ``` */ export function attachLiquidGlassTabs(track: HTMLElement, options: LiquidGlassTabsOptions = {}): LiquidGlassTabs { const selector = options.tabs ?? '[role="tab"]'; const isSelected = options.isSelected ?? defaultIsSelected; const onSelect = options.onSelect ?? defaultSelect; const vertical = options.orientation === "vertical"; const reduced = matchMedia("(prefers-reduced-motion: reduce)"); // State lives in attributes of its own, not classes: a framework re-rendering the // track or its tabs owns their class lists, and libraries set their own data-*. track.setAttribute("data-liquid-glass-tabs", vertical ? "vertical" : "horizontal"); const onGlass = options.glass === undefined ? Boolean(track.parentElement?.closest(":has(> .liquid-glass)")) : options.glass === false; track.toggleAttribute("data-liquid-glass-tabs-on-glass", onGlass); const paintAppearance = () => { track.setAttribute("data-liquid-glass-tabs-appearance", resolveAppearance(options.appearance)); }; paintAppearance(); const unwatchAppearance = watchAppearance(paintAppearance); const trackGlass: LiquidGlass | null = onGlass ? null : createLiquidGlass(track, { ...(typeof options.glass === "object" ? options.glass : {}), appearance: options.appearance, refraction: options.refraction, }); const light = span("liquid-glass-tabs-light"); const thumb = span("liquid-glass-tabs-thumb"); const lens = span("liquid-glass-tabs-lens"); thumb.append(span("liquid-glass-tabs-well"), lens); if (trackGlass) trackGlass.element.after(light, thumb); else track.prepend(light, thumb); // A rim for a lens a tab high. Four refraction samples keep the labels it bends // from stair-stepping, and it magnifies them while lifted. const lensGlass = createLiquidGlass(lens, { appearance: options.appearance, variant: "lens", geometry: LENS_GEOMETRY, refraction: options.refraction, samples: 4, }); const allTabs = () => Array.from(track.querySelectorAll(selector)).filter( tab => !tab.hasAttribute("disabled") && !tab.hasAttribute("data-disabled") && tab.getAttribute("aria-disabled") !== "true", ); const state = { selected: null as HTMLElement | null, /** The selected tab's box, where the thumb comes to rest. */ rest: null as Box | null, pressed: false, /** When a key or the pointer was last let go in the track. */ interacted: -Infinity, scrub: null as null | { pointerId: number; startX: number; active: boolean; min: number; max: number }, }; // ── Motion ── const sim = { // The lens's centre and its size before lifting and deforming. x: spring(), y: spring(), w: spring(), h: spring(), lift: spring(), platter: spring(1), stretch: spring(), swell: spring(), /** The box the lens is headed for. */ target: null as Box | null, /** While scrubbing, the centre the lens follows. */ follow: null as number | null, lifting: false, swelling: false, /** 1 or -1 along the track, the way the lens is going. */ direction: 1, /** How far the current journey goes, in widths of its tab. */ tabs: 0, squash: 0, squashFrom: -Infinity, frame: 0, last: 0, }; // Pixels to iOS's points, by the resting lens's thickness. const pointsOf = (box: Box) => Math.min(box.right - box.left, box.bottom - box.top) / LENS_PT || 1; const write = (now: number, moving = true) => { if (!sim.target) return; const pt = pointsOf(sim.target); const lift = Math.min(Math.max(sim.lift.value, 0), 1); const w = sim.w.value + 2 * LIFT_PT * pt * lift; const h = sim.h.value + 2 * LIFT_PT * pt * lift; const stretch = sim.stretch.value; const squash = sim.squash * pulse((now - sim.squashFrom) / 1000 / SQUASH_PEAK); const along = 1 + STRETCH_ALONG * stretch - squash; const across = 1 - STRETCH_ACROSS * stretch + 1.2 * squash; const sx = vertical ? across : along; const sy = vertical ? along : across; // The stretch reaches ahead: the leading end runs on while the trailing one holds back. const lead = (sim.direction * STRETCH_ALONG * Math.max(stretch, 0) * (vertical ? h : w)) / 2; const cx = sim.x.value + (vertical ? 0 : lead); const cy = sim.y.value + (vertical ? lead : 0); thumb.style.transform = `translate(${cx - (w * sx) / 2}px, ${cy - (h * sy) / 2}px)`; thumb.style.width = `${w * sx}px`; thumb.style.height = `${h * sy}px`; // styles.css fades the well, the shadow and the lens's light by these. thumb.style.setProperty("--liquid-glass-tabs-lift", lift.toFixed(3)); thumb.style.setProperty("--liquid-glass-tabs-platter", Math.min(Math.max(sim.platter.value, 0), 1).toFixed(3)); thumb.toggleAttribute("data-moving", moving); thumb.toggleAttribute("data-lifted", sim.lifting); // Over the labels while it is a lens, so it can bend them; under them at rest. thumb.toggleAttribute("data-raised", sim.lifting || lift > 0.02); lensGlass.magnify(1 + MAGNIFY * lift); // The whole bar, glass and labels, swells about its centre, by points of its length. const swell = sim.swell.value; const length = Math.max(track.offsetWidth, track.offsetHeight); track.style.scale = Math.abs(swell) > 1e-4 ? (1 + (swell * SWELL_PT * pt) / length).toFixed(5) : ""; }; const near = (value: Spring, to: number) => Math.abs(value.value - to) < 0.05 && Math.abs(value.velocity) < 2; const settled = (box: Box, now: number) => !sim.lifting && !sim.swelling && sim.follow === null && !state.pressed && near(sim.x, (box.left + box.right) / 2) && near(sim.y, (box.top + box.bottom) / 2) && near(sim.w, box.right - box.left) && near(sim.h, box.bottom - box.top) && sim.lift.value < 0.002 && sim.platter.value > 0.998 && Math.abs(sim.stretch.value) < 0.002 && Math.abs(sim.swell.value) < 0.0005 && Math.abs(sim.swell.velocity) < 0.01 && (now - sim.squashFrom) / 1000 > SQUASH_PEAK * 4; const stop = () => { if (sim.frame) cancelAnimationFrame(sim.frame); sim.frame = 0; }; const snap = (box: Box) => { stop(); sim.target = box; sim.x = spring((box.left + box.right) / 2); sim.y = spring((box.top + box.bottom) / 2); sim.w = spring(box.right - box.left); sim.h = spring(box.bottom - box.top); sim.lift = spring(); sim.platter = spring(1); sim.stretch = spring(); sim.swell = spring(); sim.lifting = false; sim.swelling = false; sim.follow = null; sim.squash = 0; write(performance.now(), false); }; const tick = (now: number) => { sim.frame = 0; const box = sim.target; if (!box) return; const pt = pointsOf(box); const tx = (box.left + box.right) / 2; const ty = (box.top + box.bottom) / 2; // A long gap (a background tab) shouldn't fling the lens. The first frame's // timestamp can precede the performance.now() it was scheduled at. let elapsed = Math.min(Math.max(now - sim.last, 0) / 1000, 1 / 20); sim.last = now; while (elapsed > 1e-6) { const dt = Math.min(STEP, elapsed); elapsed -= dt; const following = sim.follow; stepSpring(sim.x, following ?? tx, following === null ? TRAVEL : FOLLOW, dt); stepSpring(sim.y, ty, TRAVEL, dt); stepSpring(sim.w, box.right - box.left, TRAVEL, dt); stepSpring(sim.h, box.bottom - box.top, TRAVEL, dt); stepSpring(sim.lift, sim.lifting ? 1 : 0, LIFT, dt); if (sim.lifting) { sim.platter.value = 1 - sim.lift.value; sim.platter.velocity = -sim.lift.velocity; } else { stepSpring(sim.platter, 1, PLATTER, dt); } const speed = Math.abs(vertical ? sim.y.velocity : sim.x.velocity) / pt; stepSpring(sim.stretch, Math.min(speed / STRETCH_SPEED_PT, 1), STRETCH, dt); stepSpring(sim.swell, sim.swelling ? 1 : 0, SWELL, dt); } const velocity = vertical ? sim.y.velocity : sim.x.velocity; if (Math.abs(velocity) > 20 * pt) sim.direction = Math.sign(velocity); // Fully lifted and nearly there, the lens drops back in and squashes as it lands. const remaining = Math.hypot(tx - sim.x.value, ty - sim.y.value); if (sim.lifting && !state.pressed && sim.follow === null && remaining < RELEASE_PT * pt && sim.lift.value > 0.975) { sim.lifting = false; sim.squash = Math.min(SQUASH_PER_TAB * sim.tabs, SQUASH_MAX); sim.squashFrom = now; } if (settled(box, now)) return snap(box); write(now); sim.frame = requestAnimationFrame(tick); }; const run = () => { if (sim.frame || !sim.target) return; sim.last = performance.now(); sim.frame = requestAnimationFrame(tick); }; /** Heads for `box`. A change of tab the viewer made lifts the lens on its way, as a tap does. */ const travel = (box: Box, lift: boolean) => { if (reduced.matches) return snap(box); const from = vertical ? sim.y.value : sim.x.value; const to = vertical ? (box.top + box.bottom) / 2 : (box.left + box.right) / 2; const size = vertical ? box.bottom - box.top : box.right - box.left; sim.target = box; sim.tabs = Math.abs(to - from) / Math.max(size, 1); if (Math.abs(to - from) > 0.5) sim.direction = Math.sign(to - from); if (lift) sim.lifting = true; run(); }; /** Lifts the lens where it is, for a press on the selected tab. */ const press = () => { if (reduced.matches || !sim.target) return; sim.lifting = true; sim.tabs = 0; run(); }; /** Scrubbing, keeps the lens after `center`; null hands it back to its tab. */ const followPointer = (center: number | null) => { sim.follow = center; run(); }; const swell = (swelling: boolean) => { if (reduced.matches) return; sim.swelling = swelling; run(); }; /** A resized tab: mid-motion the lens heads for its new box, at rest it moves there at once. */ const retarget = (box: Box) => { if (sim.frame || state.pressed) sim.target = box; else snap(box); }; // ── Selection ── const follow = () => { const tabs = allTabs(); const selected = tabs.find(isSelected) ?? null; for (const tab of tabs) { tab.setAttribute("data-liquid-glass-tab", ""); tab.toggleAttribute("data-liquid-glass-tab-selected", tab === selected); } const previous = state.selected; state.selected = selected; if (!selected) { state.rest = null; thumb.toggleAttribute("data-shown", false); return; } const box = restBox(selected, track, vertical); // In a horizontal track the thumb's corners follow the track's, inset by the gap // between them, as iOS nests its glass. A capsule track holds a capsule thumb, // and a capsule stays a capsule as the lens grows. const trackRadius = Math.min(parseFloat(getComputedStyle(track).borderTopLeftRadius) || 0, track.offsetHeight / 2); const concentric = vertical ? 0 : trackRadius - track.clientTop - box.top; const radius = Math.max(parseFloat(getComputedStyle(selected).borderTopLeftRadius) || 0, concentric); thumb.style.borderRadius = radius >= (box.bottom - box.top) / 2 - 0.5 ? "9999px" : `${radius}px`; const appearing = !state.rest; state.rest = box; thumb.toggleAttribute("data-shown", true); // A tab chosen with the pointer is under it now. track.toggleAttribute(HOVER, track.hasAttribute(POINTER) && selected.matches(":hover")); if (appearing) snap(box); // A tab the viewer picked lifts the lens on its way, as a tap does on iOS; one // chosen for them (a route, your own state) slides flat. else if (previous !== selected) travel(box, state.pressed || performance.now() - state.interacted < INTERACTION_MS); else retarget(box); }; // Tabs resize as labels change, fonts load and the track reflows. const resize = new ResizeObserver(() => { const selected = state.selected; if (!selected) return; const box = restBox(selected, track, vertical); const previous = state.rest; state.rest = box; if (state.scrub?.active) return; if (sim.frame) retarget(box); else if (!previous || !sameBox(previous, box)) snap(box); }); const observeSizes = () => { resize.disconnect(); resize.observe(track); for (const tab of allTabs()) resize.observe(tab); }; const mutations = new MutationObserver(records => { if (records.some(record => record.type === "childList")) observeSizes(); follow(); }); mutations.observe(track, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-selected", "aria-checked", "data-state", "disabled", "data-disabled", "aria-disabled"], }); // ── Pointer: light, press and scrubbing ── const selectedTab = () => state.selected; // The track's own frame, with its swell undone. const toTrack = (clientX: number, clientY = 0) => { const frame = track.getBoundingClientRect(); const scale = track.offsetWidth ? frame.width / track.offsetWidth || 1 : 1; return { x: (clientX - frame.left) / scale - track.clientLeft + track.scrollLeft, y: (clientY - frame.top) / scale - track.clientTop + track.scrollTop, left: (clientX - frame.left) / scale, top: (clientY - frame.top) / scale, }; }; // The light follows the pointer through its own properties: set on the track they // would be inherited, restyling every tab on every move. const placeLight = (event: PointerEvent) => { const { left, top } = toTrack(event.clientX, event.clientY); light.style.setProperty("--liquid-glass-tabs-x", `${left}px`); light.style.setProperty("--liquid-glass-tabs-y", `${top}px`); }; // A press brightens the glass under it; letting go, the light spreads across the // bar as it fades. let spreading = 0; const setLight = (phase: "press" | "release" | null) => { window.clearTimeout(spreading); track.toggleAttribute(PRESS, phase === "press"); track.toggleAttribute(RELEASE, phase === "release"); if (phase === "release") spreading = window.setTimeout(() => setLight(null), 500); }; const onPointerEnter = (event: PointerEvent) => { if (event.pointerType !== "touch") track.toggleAttribute(POINTER, true); }; const onPointerLeave = () => { track.removeAttribute(POINTER); track.removeAttribute(HOVER); }; const onKeyDown = () => { state.interacted = performance.now(); }; const onPointerMove = (event: PointerEvent) => { if (event.pointerType !== "touch" || state.pressed) { placeLight(event); // Brighten the well under the pointer. An attribute, not a `:has()` rule, so // changing tabs doesn't restyle the whole track. const overSelected = event.target instanceof Node && Boolean(selectedTab()?.contains(event.target)); if (track.hasAttribute(HOVER) !== overSelected) { track.toggleAttribute(HOVER, overSelected); } } const scrub = state.scrub; const rest = state.rest; if (!scrub || !rest || event.pointerId !== scrub.pointerId) return; if (!scrub.active) { if (Math.abs(event.clientX - scrub.startX) < SCRUB_THRESHOLD) return; scrub.active = true; track.setPointerCapture(event.pointerId); } const half = (rest.right - rest.left) / 2; followPointer(Math.min(Math.max(toTrack(event.clientX).x, scrub.min + half), scrub.max - half)); }; const onRelease = (event: PointerEvent) => { window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); state.pressed = false; state.interacted = performance.now(); setLight("release"); swell(false); const scrub = state.scrub; state.scrub = null; if (!scrub?.active) { // The lens drops back in once it has fully lifted and arrived. run(); return; } followPointer(null); if (track.hasPointerCapture(scrub.pointerId)) track.releasePointerCapture(scrub.pointerId); // Land on the tab under the pointer, as iOS does, or the end tab past either // end. Not the tab nearest the lens: the lens keeps the selected tab's width // inside the track, so beside narrower tabs its centre never reaches the end ones. const pointer = toTrack(event.clientX).x; let nearest: HTMLElement | null = null; let nearestGap = Infinity; let nearestOffset = Infinity; if (event.type === "pointerup") { for (const tab of allTabs()) { const box = measure(tab, track); // How far outside the tab the pointer is (0 over it), then from its centre. const gap = Math.max(box.left - pointer, 0, pointer - box.right); const offset = Math.abs((box.left + box.right) / 2 - pointer); if (gap < nearestGap || (gap === nearestGap && offset < nearestOffset)) { nearestGap = gap; nearestOffset = offset; nearest = tab; } } } const before = state.selected; if (nearest && nearest !== selectedTab()) { onSelect(nearest); // Tabs that don't change (or change later, after navigating) send the lens home. requestAnimationFrame(() => { if (state.selected === before && state.rest) travel(state.rest, true); }); } else if (state.rest) { travel(state.rest, true); } }; const onPointerDown = (event: PointerEvent) => { if (event.button !== 0 || !state.rest || reduced.matches) return; const tab = event.target instanceof Element ? event.target.closest(selector) : null; if (!tab || !allTabs().includes(tab)) return; state.pressed = true; window.addEventListener("pointerup", onRelease); window.addEventListener("pointercancel", onRelease); placeLight(event); setLight("press"); swell(true); // Another tab: selecting it sends the lens there. if (tab !== selectedTab()) return; if (!vertical) { const boxes = allTabs().map(item => measure(item, track)); state.scrub = { pointerId: event.pointerId, startX: event.clientX, active: false, min: Math.min(...boxes.map(box => box.left)), max: Math.max(...boxes.map(box => box.right)), }; } press(); }; // Links would start a native drag instead of scrubbing the thumb. const onDragStart = (event: DragEvent) => { if (event.target instanceof Element && event.target.closest(selector)) event.preventDefault(); }; track.addEventListener("pointerenter", onPointerEnter); track.addEventListener("pointerleave", onPointerLeave); track.addEventListener("pointermove", onPointerMove); track.addEventListener("pointerdown", onPointerDown); track.addEventListener("keydown", onKeyDown); track.addEventListener("dragstart", onDragStart); observeSizes(); follow(); return { update() { observeSizes(); follow(); }, destroy() { stop(); window.clearTimeout(spreading); mutations.disconnect(); resize.disconnect(); unwatchAppearance(); track.removeEventListener("pointerenter", onPointerEnter); track.removeEventListener("pointerleave", onPointerLeave); track.removeEventListener("pointermove", onPointerMove); track.removeEventListener("pointerdown", onPointerDown); track.removeEventListener("keydown", onKeyDown); track.removeEventListener("dragstart", onDragStart); window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); track.style.scale = ""; lensGlass.destroy(); trackGlass?.destroy(); light.remove(); thumb.remove(); for (const tab of allTabs()) { tab.removeAttribute("data-liquid-glass-tab"); tab.removeAttribute("data-liquid-glass-tab-selected"); } for (const attribute of [ "data-liquid-glass-tabs", "data-liquid-glass-tabs-appearance", "data-liquid-glass-tabs-on-glass", POINTER, HOVER, PRESS, RELEASE, ]) { track.removeAttribute(attribute); } }, }; }