import { createLiquidGlass, prewarmLiquidGlass, resolveAppearance, watchAppearance, type LiquidGlass, type LiquidGlassOptions, } from "./glass.js"; import { LENS_GEOMETRY } from "./lens.js"; import { LENS_PT, spring, STEP, stepSpring, type Spring, type SpringConfig } from "./spring.js"; /* Liquid Glass tabs, after iOS 27's segmented control. The track is a glass capsule and the selected tab a solid thumb on it, white in light and grey in dark. One thumb travels between tabs as a single piece of glass: it clears into a clear lens larger than the track, over the labels, stretches along its path, and drops back in, refilling as it lands. Pressing the selected tab lifts it in place, and dragging it scrubs across the tabs. A vertical list is a sidebar rather than a segmented control: its selection is a well in the glass, and its lens grows less. Every fade is a filter or a colour, 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 segmented control, measured frame by frame * from SwiftUI Picker recordings, and scaled by the thumb's thickness, 54 pt on * iOS's tab bar. Lengths below are in those points. The lens's centre and size ride * one spring; lifting, it grows past the track and its fill clears; it stretches * along its path with its speed. Labels beneath it bend at its rim but aren't * magnified, and the track holds still, as iOS's do. Where iOS's motion is abrupt, * it is smoothed: the lens lands without a squash, settles without wobbling, and * drops in and refills over a little longer than iOS's snap. */ // 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: critically damped, a clear lens in about 100 ms, as iOS's // thumb is, so it answers the touch at once. const LIFT: SpringConfig = { omega: (2 * Math.PI) / 0.18, zeta: 1 }; // Dropping back in is gentler, over about 200 ms, and the fill refills from its rim // alongside it. const DROP: SpringConfig = { omega: (2 * Math.PI) / 0.3, zeta: 1 }; const PLATTER: SpringConfig = { omega: (2 * Math.PI) / 0.3, 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 eases out as the lens // stops, without swinging past rest. const STRETCH: SpringConfig = { omega: 22, zeta: 0.95 }; // Lifted, the lens grows this much at each end and at its top and bottom, standing // about a tenth of the track proud of it above and below. iOS's grows further, to // 1.5 times the track, which held under the pointer read as too big on web-sized // tabs. A vertical list's lens grows less, so it stays clear of the rows beside it. const LIFT_ALONG_PT = 15; const LIFT_ACROSS_PT = 13; const LIFT_VERTICAL_PT = 10; // Within this of its tab, the lens drops back in, once fully lifted and no sooner // than this long after it lifted: iOS's lens hangs at the tab it reached until // about 0.3 s after the touch, then refills. const RELEASE_PT = 3.5; const HANG_MS = 280; // 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; 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; // Corner radii a lens passes through as it lifts, built ahead of time so its rim is // drawn for its exact size each frame rather than stretched, coarse and thick. const LENS_RADII = Array.from({ length: 21 }, (_, index) => 8 + index); // 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"; // While a drag scrubs the lens, and on the tab it would land on. const SCRUBBING = "data-liquid-glass-tabs-scrubbing"; const TARGET = "data-liquid-glass-tab-target"; /** * 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; } // Whether the lens's radii are built or on their way. let lensPrewarmed = false; /** * 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 trackOptions = typeof options.glass === "object" ? options.glass : {}; const trackGlass: LiquidGlass | null = onGlass ? null : createLiquidGlass(track, { // A segmented control's track is a grey a little darker than the page in // light, so the white thumb stands out on it (--liquid-glass-tabs-tint). tint: "var(--liquid-glass-tabs-tint)", ...trackOptions, appearance: options.appearance, refraction: options.refraction, }); const light = span("liquid-glass-tabs-light"); // The solid thumb's fill rides with the lens on a plate of its own, always under // the labels: the lens rises over them to bend them, and a fill in it would hide them. const plate = span("liquid-glass-tabs-plate"); plate.append(span("liquid-glass-tabs-fill")); 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, plate, thumb); else track.prepend(light, plate, thumb); // A rim for a lens a tab high, with four refraction samples so the labels it bends // come out smooth. const lensGlass = createLiquidGlass(lens, { appearance: options.appearance, variant: "lens", geometry: LENS_GEOMETRY, refraction: options.refraction, }); if (!lensPrewarmed) { lensPrewarmed = true; prewarmLiquidGlass(LENS_RADII, LENS_GEOMETRY); } 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(), /** 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, /** When the lens last began to lift. */ liftFrom: -Infinity, /** 1 or -1 along the track, the way the lens is going. */ direction: 1, 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 = (moving = true) => { if (!sim.target) return; const pt = pointsOf(sim.target); const lift = Math.min(Math.max(sim.lift.value, 0), 1); const growAlong = 2 * (vertical ? LIFT_VERTICAL_PT : LIFT_ALONG_PT) * pt * lift; const growAcross = 2 * (vertical ? LIFT_VERTICAL_PT : LIFT_ACROSS_PT) * pt * lift; const w = sim.w.value + (vertical ? growAcross : growAlong); const h = sim.h.value + (vertical ? growAlong : growAcross); const stretch = sim.stretch.value; const along = 1 + STRETCH_ALONG * stretch; const across = 1 - STRETCH_ACROSS * stretch; 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); for (const element of [plate, thumb]) { element.style.transform = `translate(${cx - (w * sx) / 2}px, ${cy - (h * sy) / 2}px)`; element.style.width = `${w * sx}px`; element.style.height = `${h * sy}px`; } // styles.css fades the fill, the well, the shadow and the lens's rim by these. const platter = Math.min(Math.max(sim.platter.value, 0), 1).toFixed(3); thumb.style.setProperty("--liquid-glass-tabs-lift", lift.toFixed(3)); thumb.style.setProperty("--liquid-glass-tabs-platter", platter); plate.style.setProperty("--liquid-glass-tabs-platter", platter); 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); }; const near = (value: Spring, to: number) => Math.abs(value.value - to) < 0.05 && Math.abs(value.velocity) < 2; const settled = (box: Box) => !sim.lifting && 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; 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.lifting = false; sim.follow = null; write(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, sim.lifting ? LIFT : DROP, 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); } const velocity = vertical ? sim.y.velocity : sim.x.velocity; if (Math.abs(velocity) > 20 * pt) sim.direction = Math.sign(velocity); // Fully lifted, hung at its tab for a moment and nearly there, the lens drops back in. 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 && now - sim.liftFrom > HANG_MS ) { sim.lifting = false; } if (settled(box)) return snap(box); write(); sim.frame = requestAnimationFrame(tick); }; const run = () => { if (sim.frame || !sim.target) return; sim.last = performance.now(); sim.frame = requestAnimationFrame(tick); }; const startLift = () => { if (!sim.lifting) sim.liftFrom = performance.now(); sim.lifting = true; }; /** 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; sim.target = box; if (Math.abs(to - from) > 0.5) sim.direction = Math.sign(to - from); if (lift) startLift(); run(); }; /** Lifts the lens where it is, for a press on the selected tab. */ const press = () => { if (reduced.matches || !sim.target) return; startLift(); run(); }; /** Scrubbing, keeps the lens after `center`; null hands it back to its tab. */ const followPointer = (center: number | null) => { sim.follow = center; 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); plate.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`; plate.style.borderRadius = thumb.style.borderRadius; const appearing = !state.rest; state.rest = box; thumb.toggleAttribute("data-shown", true); plate.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 any scale on it 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 a little; 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); }; // The tab under the pointer, as iOS lands a drag on it, 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 tabAt = (pointer: number) => { let nearest: HTMLElement | null = null; let nearestGap = Infinity; let nearestOffset = Infinity; 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; } } return nearest; }; // While scrubbing, the tab the lens would land on takes the selected label's // weight, as iOS's does, before it is chosen. let scrubTarget: HTMLElement | null = null; const markScrubTarget = (tab: HTMLElement | null) => { if (tab === scrubTarget) return; scrubTarget?.removeAttribute(TARGET); tab?.setAttribute(TARGET, ""); scrubTarget = tab; track.toggleAttribute(SCRUBBING, tab !== null); }; 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; const pointer = toTrack(event.clientX).x; followPointer(Math.min(Math.max(pointer, scrub.min + half), scrub.max - half)); markScrubTarget(tabAt(pointer)); }; const onRelease = (event: PointerEvent) => { window.removeEventListener("pointerup", onRelease); window.removeEventListener("pointercancel", onRelease); state.pressed = false; state.interacted = performance.now(); setLight("release"); markScrubTarget(null); 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); const nearest = event.type === "pointerup" ? tabAt(toTrack(event.clientX).x) : null; 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"); // 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); markScrubTarget(null); lensGlass.destroy(); trackGlass?.destroy(); light.remove(); plate.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); } }, }; }