From c2ccb468b090add34e0799bc1e4d92a3f621cee5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 10:58:46 -0700 Subject: [PATCH 1/4] fix(tooltip): remove velocity skew/scale that blurred text on every appear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tooltip animated a fractional scale() + skew() over 150ms on the element containing its text. Chrome promotes the bubble to a compositor layer for the transition, rasterizes the text once at the pre-transition scale, then GPU-resamples that bitmap for the duration — so text rendered blurry until the transition settled and the layer re-rasterized at 1:1. It fired on every appear: a pointer entering a trigger is by definition moving, so the first pointermove after pointerenter always set a non-zero skew and a fractional scale. - drop the velocity-reactive skew/scale flourish and the pointer-velocity bookkeeping that existed only to feed it - round tooltip position to whole pixels; clientX/clientY are fractional on HiDPI/zoomed displays, leaving the bubble on a subpixel boundary - drop the dead `filter` from the transition list — nothing ever set a filter - skip the state update when the rounded position is unchanged, so pointer jitter no longer re-renders every Tooltip.Trigger/Content consumer The 150ms ease-out translate is kept, so the bubble still trails the cursor. --- .../emcn/src/components/tooltip/tooltip.tsx | 86 +++++-------------- 1 file changed, 23 insertions(+), 63 deletions(-) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 6dc3401cf11..dc4eac0e3f8 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -8,31 +8,20 @@ import { cn } from '../../lib/cn' const TOOLTIP_OFFSET = 16 const EDGE_GUTTER = 16 const EDGE_THRESHOLD = 360 -const MIN_FRAME_MS = 16 /** - * Resolved position and motion of a floating tooltip. `x`/`y` are viewport + * Resolved position of a floating tooltip. `x`/`y` are whole-pixel viewport * coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away - * from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive - * flourish while the pointer is moving. + * from the nearest viewport edge. */ export interface FloatingTooltipState { visible: boolean x: number y: number - skew: number - scaleX: number - scaleY: number alignX: 'left' | 'right' alignY: 'above' | 'below' } -interface PointerSnapshot { - x: number - y: number - time: number -} - /** * Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread * onto the element that should reveal the tooltip on hover or focus. @@ -50,9 +39,6 @@ const HIDDEN_STATE: FloatingTooltipState = { visible: false, x: 0, y: 0, - skew: 0, - scaleX: 1, - scaleY: 1, alignX: 'left', alignY: 'below', } @@ -71,48 +57,32 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { const canShowRef = React.useRef(canShow) canShowRef.current = canShow - const lastPointerRef = React.useRef(null) const [state, setState] = React.useState(HIDDEN_STATE) const handlers = React.useMemo(() => { - const hide = () => { - lastPointerRef.current = null - setState((current) => (current.visible ? HIDDEN_STATE : current)) - } - - const showStatic = (clientX: number, clientY: number) => { - lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() } - setState({ - visible: true, - ...getTooltipPosition(clientX, clientY), - skew: 0, - scaleX: 1, - scaleY: 1, - }) + const hide = () => setState((current) => (current.visible ? HIDDEN_STATE : current)) + + const show = (clientX: number, clientY: number) => { + const next = getTooltipPosition(clientX, clientY) + setState((current) => + current.visible && + current.x === next.x && + current.y === next.y && + current.alignX === next.alignX && + current.alignY === next.alignY + ? current + : { visible: true, ...next } + ) } return { onPointerEnter: (event) => { if (!canShowRef.current(event.currentTarget)) return - showStatic(event.clientX, event.clientY) + show(event.clientX, event.clientY) }, onPointerMove: (event) => { if (!canShowRef.current(event.currentTarget)) return - const now = performance.now() - const previous = lastPointerRef.current - const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS - const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0 - const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0 - const velocity = Math.hypot(velocityX, velocityY) - - lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now } - setState({ - visible: true, - ...getTooltipPosition(event.clientX, event.clientY), - skew: clamp(velocityX * 0.11, -6, 6), - scaleX: 1 + Math.min(0.035, velocity / 1100), - scaleY: 1 - Math.min(0.02, velocity / 1500), - }) + show(event.clientX, event.clientY) }, onPointerLeave: hide, onPointerDown: hide, @@ -121,14 +91,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { if (!canShowRef.current(target)) return if (!isFocusVisible(target)) return const rect = target.getBoundingClientRect() - lastPointerRef.current = null - setState({ - visible: true, - ...getTooltipPosition(rect.left + rect.width / 2, rect.bottom), - skew: 0, - scaleX: 1, - scaleY: 1, - }) + show(rect.left + rect.width / 2, rect.bottom) }, onBlur: hide, } @@ -248,14 +211,11 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ aria-hidden={role ? undefined : 'true'} data-native-surface-overlay='' className={cn( - 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out', + 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,transform] duration-150 ease-out', 'motion-reduce:transition-none', className )} - style={{ - transform: `${getTooltipTranslate(state, offset)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`, - transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px', - }} + style={{ transform: getTooltipTranslate(state, offset) }} > {children ?? {label}} , @@ -268,15 +228,15 @@ function getTooltipPosition( clientY: number ): Pick { if (typeof window === 'undefined') { - return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' } + return { x: Math.round(clientX), y: Math.round(clientY), alignX: 'left', alignY: 'below' } } const alignX = window.innerWidth - clientX < EDGE_THRESHOLD ? 'right' : 'left' const alignY = window.innerHeight - clientY < EDGE_THRESHOLD / 2 ? 'above' : 'below' return { - x: clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER), - y: clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER), + x: Math.round(clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER)), + y: Math.round(clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER)), alignX, alignY, } From fc4a6797206a735cd906fabea51585a1529810a3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 11:05:53 -0700 Subject: [PATCH 2/4] improvement(tooltip): keep the velocity flourish, drive it without a CSS transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the velocity-reactive skew/scale removed in the previous commit. The flourish was never the problem on its own — handing it to a CSS transition was. An interpolated fractional scale makes the compositor rasterize the tooltip's text once and resample that bitmap for the duration, which is what read as blur. Applied as a static value per pointer event instead, so every frame is rasterized at its own scale: - split the transform across the individual `translate`, `scale`, and `transform: skew()` properties, and transition only `translate` — position still eases toward the cursor, the flourish no longer interpolates - smooth the pointer velocity in JS (low-pass filter) to replace the smoothing the CSS transition used to provide, so the squish still ramps rather than snapping between raw per-event velocities - quantize the flourish to 3 decimals so jitter below the visible threshold settles instead of re-rendering every consumer Whole-pixel position rounding and the redundant-update bail-out are unchanged. --- .../emcn/src/components/tooltip/tooltip.tsx | 115 +++++++++++++++--- 1 file changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index dc4eac0e3f8..189644cb2e7 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -8,20 +8,49 @@ import { cn } from '../../lib/cn' const TOOLTIP_OFFSET = 16 const EDGE_GUTTER = 16 const EDGE_THRESHOLD = 360 +const MIN_FRAME_MS = 16 /** - * Resolved position of a floating tooltip. `x`/`y` are whole-pixel viewport - * coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away - * from the nearest viewport edge. + * How much of the gap between the smoothed and the instantaneous pointer velocity + * is closed per pointer event. This is what softens the velocity flourish — the + * transform itself is never handed to a CSS transition, because a compositor- + * interpolated fractional scale forces the tooltip's rasterized text to be + * resampled, which reads as a blur until the interpolation settles. + */ +const VELOCITY_SMOOTHING = 0.35 + +/** + * Resolved position and motion of a floating tooltip. `x`/`y` are whole-pixel + * viewport coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip + * away from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive + * flourish while the pointer is moving. */ export interface FloatingTooltipState { visible: boolean x: number y: number + skew: number + scaleX: number + scaleY: number alignX: 'left' | 'right' alignY: 'above' | 'below' } +/** Velocity-derived flourish applied to the tooltip on a given frame. */ +interface TooltipMotion { + skew: number + scaleX: number + scaleY: number +} + +const NEUTRAL_MOTION: TooltipMotion = { skew: 0, scaleX: 1, scaleY: 1 } + +interface PointerSnapshot { + x: number + y: number + time: number +} + /** * Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread * onto the element that should reveal the tooltip on hover or focus. @@ -39,6 +68,7 @@ const HIDDEN_STATE: FloatingTooltipState = { visible: false, x: 0, y: 0, + ...NEUTRAL_MOTION, alignX: 'left', alignY: 'below', } @@ -57,32 +87,68 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { const canShowRef = React.useRef(canShow) canShowRef.current = canShow + const lastPointerRef = React.useRef(null) + const velocityRef = React.useRef({ x: 0, magnitude: 0 }) const [state, setState] = React.useState(HIDDEN_STATE) const handlers = React.useMemo(() => { - const hide = () => setState((current) => (current.visible ? HIDDEN_STATE : current)) + const reset = () => { + lastPointerRef.current = null + velocityRef.current.x = 0 + velocityRef.current.magnitude = 0 + } - const show = (clientX: number, clientY: number) => { - const next = getTooltipPosition(clientX, clientY) + const hide = () => { + reset() + setState((current) => (current.visible ? HIDDEN_STATE : current)) + } + + const apply = (clientX: number, clientY: number, motion: TooltipMotion) => { + const next = { ...getTooltipPosition(clientX, clientY), ...motion } setState((current) => current.visible && current.x === next.x && current.y === next.y && current.alignX === next.alignX && - current.alignY === next.alignY + current.alignY === next.alignY && + current.skew === next.skew && + current.scaleX === next.scaleX && + current.scaleY === next.scaleY ? current : { visible: true, ...next } ) } + const showNeutral = (clientX: number, clientY: number) => { + reset() + lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() } + apply(clientX, clientY, NEUTRAL_MOTION) + } + return { onPointerEnter: (event) => { if (!canShowRef.current(event.currentTarget)) return - show(event.clientX, event.clientY) + showNeutral(event.clientX, event.clientY) }, onPointerMove: (event) => { if (!canShowRef.current(event.currentTarget)) return - show(event.clientX, event.clientY) + const now = performance.now() + const previous = lastPointerRef.current + const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS + const instantX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0 + const instantY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0 + + const velocity = velocityRef.current + velocity.x += (instantX - velocity.x) * VELOCITY_SMOOTHING + velocity.magnitude += + (Math.hypot(instantX, instantY) - velocity.magnitude) * VELOCITY_SMOOTHING + + lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now } + apply(event.clientX, event.clientY, { + skew: quantize(clamp(velocity.x * 0.11, -6, 6)), + scaleX: quantize(1 + Math.min(0.035, velocity.magnitude / 1100)), + scaleY: quantize(1 - Math.min(0.02, velocity.magnitude / 1500)), + }) }, onPointerLeave: hide, onPointerDown: hide, @@ -91,7 +157,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { if (!canShowRef.current(target)) return if (!isFocusVisible(target)) return const rect = target.getBoundingClientRect() - show(rect.left + rect.width / 2, rect.bottom) + showNeutral(rect.left + rect.width / 2, rect.bottom) }, onBlur: hide, } @@ -159,6 +225,14 @@ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } +/** + * Rounds a flourish value to 3 decimals so pointer jitter below the visible + * threshold settles to a stable number instead of re-rendering every consumer. + */ +function quantize(value: number): number { + return Math.round(value * 1000) / 1000 +} + /** * Whether an element currently matches `:focus-visible` (keyboard focus, not focus produced by a * mouse click). Used to keep the tooltip from re-appearing/repositioning when the trigger is @@ -211,11 +285,16 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ aria-hidden={role ? undefined : 'true'} data-native-surface-overlay='' className={cn( - 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,transform] duration-150 ease-out', + 'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,translate] duration-150 ease-out', 'motion-reduce:transition-none', className )} - style={{ transform: getTooltipTranslate(state, offset) }} + style={{ + translate: getTooltipTranslate(state, offset), + scale: `${state.scaleX} ${state.scaleY}`, + transform: `skew(${state.skew}deg)`, + transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px', + }} > {children ?? {label}} , @@ -242,11 +321,17 @@ function getTooltipPosition( } } +/** + * Value for the `translate` CSS property. Kept off the `transform` property so the + * velocity flourish (`scale` + `transform: skew()`) can stay out of the transition + * list while the tooltip's position still eases toward the cursor. + */ function getTooltipTranslate(state: FloatingTooltipState, offset: number): string { - const xOffset = state.alignX === 'left' ? `${offset}px` : `calc(-100% - ${offset}px)` - const yOffset = state.alignY === 'below' ? `${offset}px` : `calc(-100% - ${offset}px)` + const x = state.alignX === 'left' ? `${state.x + offset}px` : `calc(${state.x - offset}px - 100%)` + const y = + state.alignY === 'below' ? `${state.y + offset}px` : `calc(${state.y - offset}px - 100%)` - return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})` + return `${x} ${y}` } /** From 82c28e87933a638b092dd3b783c69db09422ebde Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 11:16:45 -0700 Subject: [PATCH 3/4] fix(tooltip): don't seed pointer velocity from the trigger box on focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit routed `onFocus` through a shared reveal helper that seeds `lastPointerRef` from the coordinates it is given. For focus those are the trigger's box center, not the pointer — so if the pointer already happened to be over the trigger, the next `pointermove` measured the box-to-cursor delta as velocity and spiked the skew/scale flourish. Split the helper in two: reveal-from-pointer seeds velocity tracking, reveal-from-element leaves it cleared. Restores the pre-PR behavior, where focus explicitly nulled the pointer snapshot. Caught by Cursor Bugbot. --- .../emcn/src/components/tooltip/tooltip.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 189644cb2e7..3093b0d4f5c 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -119,16 +119,28 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { ) } - const showNeutral = (clientX: number, clientY: number) => { + /** Reveals the tooltip at the pointer, seeding velocity tracking from it. */ + const showFromPointer = (clientX: number, clientY: number) => { reset() lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() } apply(clientX, clientY, NEUTRAL_MOTION) } + /** + * Reveals the tooltip anchored to an element's box rather than the pointer. + * Velocity tracking stays cleared: seeding it from the box would make the next + * `pointermove` read the box-to-cursor delta as velocity and spike the flourish + * when the pointer already happens to be over the trigger. + */ + const showFromElement = (clientX: number, clientY: number) => { + reset() + apply(clientX, clientY, NEUTRAL_MOTION) + } + return { onPointerEnter: (event) => { if (!canShowRef.current(event.currentTarget)) return - showNeutral(event.clientX, event.clientY) + showFromPointer(event.clientX, event.clientY) }, onPointerMove: (event) => { if (!canShowRef.current(event.currentTarget)) return @@ -157,7 +169,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { if (!canShowRef.current(target)) return if (!isFocusVisible(target)) return const rect = target.getBoundingClientRect() - showNeutral(rect.left + rect.width / 2, rect.bottom) + showFromElement(rect.left + rect.width / 2, rect.bottom) }, onBlur: hide, } From bc56dfdd8976daa7eca4e81c15549383859b3d38 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 11:26:36 -0700 Subject: [PATCH 4/4] fix(tooltip): make the flourish smoothing frame-rate independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The velocity low-pass filter applied a fixed coefficient per pointer event, so how fast the squish settled depended on how fast the device emitted events — 233ms at 30Hz down to 29ms at 240Hz, an 8x spread for the same gesture. It was also far snappier than the 150ms CSS ease-out it replaced, so the flourish read as twitchier than before. Derive the coefficient from the real elapsed time instead (1 - exp(-dt / tau), tau = 50ms). Settling is now flat at ~150ms from 60Hz upward, matching the duration of the transition this stands in for. Also separates the smoothing delta from the velocity-normalization delta: the latter is still floored at one frame to keep a 1ms event from reporting an enormous velocity, but flooring the former was itself a source of frame-rate dependence below 16ms. Verified against Chrome's documented re-raster behavior: a layer is re-rastered at its new scale when the scale changes via script, but not when a declarative animation interpolates it, which is why the flourish must stay out of the transition list. https://developer.chrome.com/blog/re-rastering-composite --- .../emcn/src/components/tooltip/tooltip.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 3093b0d4f5c..e7c02249646 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -11,13 +11,18 @@ const EDGE_THRESHOLD = 360 const MIN_FRAME_MS = 16 /** - * How much of the gap between the smoothed and the instantaneous pointer velocity - * is closed per pointer event. This is what softens the velocity flourish — the - * transform itself is never handed to a CSS transition, because a compositor- - * interpolated fractional scale forces the tooltip's rasterized text to be - * resampled, which reads as a blur until the interpolation settles. + * Exponential time constant for smoothing the pointer velocity that drives the + * flourish, in ms. The flourish is deliberately never handed to a CSS transition: + * Chrome only re-rasters a layer at its new scale when the scale changes via + * script, not when a declarative animation interpolates it, so a transitioned + * fractional scale leaves the tooltip's text resampled from a stale bitmap until + * the animation settles — which is what read as a blur on every appear. + * + * Smoothing here replaces the smoothing that transition used to provide. ~3x the + * time constant is where the value has effectively settled, so 50ms reproduces + * the feel of the 150ms ease-out it stands in for. */ -const VELOCITY_SMOOTHING = 0.35 +const VELOCITY_TIME_CONSTANT_MS = 50 /** * Resolved position and motion of a floating tooltip. `x`/`y` are whole-pixel @@ -146,14 +151,19 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { if (!canShowRef.current(event.currentTarget)) return const now = performance.now() const previous = lastPointerRef.current - const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS - const instantX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0 - const instantY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0 - + const delta = previous ? Math.max(now - previous.time, 1) : MIN_FRAME_MS + const perFrame = Math.max(delta, MIN_FRAME_MS) + const instantX = previous ? ((event.clientX - previous.x) / perFrame) * MIN_FRAME_MS : 0 + const instantY = previous ? ((event.clientY - previous.y) / perFrame) * MIN_FRAME_MS : 0 + + /** + * Derived from the real elapsed time rather than applied per event, so a + * 120Hz pointer and a 60Hz one settle over the same wall-clock duration. + */ + const smoothing = 1 - Math.exp(-delta / VELOCITY_TIME_CONSTANT_MS) const velocity = velocityRef.current - velocity.x += (instantX - velocity.x) * VELOCITY_SMOOTHING - velocity.magnitude += - (Math.hypot(instantX, instantY) - velocity.magnitude) * VELOCITY_SMOOTHING + velocity.x += (instantX - velocity.x) * smoothing + velocity.magnitude += (Math.hypot(instantX, instantY) - velocity.magnitude) * smoothing lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now } apply(event.clientX, event.clientY, {