fix(tooltip): stop text blurring for ~150ms every time a tooltip appears (#6211)

* fix(tooltip): remove velocity skew/scale that blurred text on every appear

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.

* improvement(tooltip): keep the velocity flourish, drive it without a CSS transition

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.

* fix(tooltip): don't seed pointer velocity from the trigger box on focus

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.

* fix(tooltip): make the flourish smoothing frame-rate independent

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
This commit is contained in:
Waleed
2026-08-03 11:37:23 -07:00
committed by GitHub
parent 6806fc2f41
commit 806ea0c5bb
+109 -42
View File
@@ -11,9 +11,23 @@ const EDGE_THRESHOLD = 360
const MIN_FRAME_MS = 16
/**
* Resolved position and motion of a floating tooltip. `x`/`y` are viewport
* coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away
* from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive
* 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_TIME_CONSTANT_MS = 50
/**
* 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 {
@@ -27,6 +41,15 @@ export interface FloatingTooltipState {
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
@@ -50,9 +73,7 @@ const HIDDEN_STATE: FloatingTooltipState = {
visible: false,
x: 0,
y: 0,
skew: 0,
scaleX: 1,
scaleY: 1,
...NEUTRAL_MOTION,
alignX: 'left',
alignY: 'below',
}
@@ -72,46 +93,83 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
canShowRef.current = canShow
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
const hide = () => {
const reset = () => {
lastPointerRef.current = null
velocityRef.current.x = 0
velocityRef.current.magnitude = 0
}
const hide = () => {
reset()
setState((current) => (current.visible ? HIDDEN_STATE : current))
}
const showStatic = (clientX: number, clientY: number) => {
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.skew === next.skew &&
current.scaleX === next.scaleX &&
current.scaleY === next.scaleY
? current
: { visible: true, ...next }
)
}
/** 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() }
setState({
visible: true,
...getTooltipPosition(clientX, clientY),
skew: 0,
scaleX: 1,
scaleY: 1,
})
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
showStatic(event.clientX, event.clientY)
showFromPointer(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)
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) * smoothing
velocity.magnitude += (Math.hypot(instantX, instantY) - velocity.magnitude) * smoothing
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),
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,
@@ -121,14 +179,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,
})
showFromElement(rect.left + rect.width / 2, rect.bottom)
},
onBlur: hide,
}
@@ -196,6 +247,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
@@ -248,12 +307,14 @@ 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,translate] duration-150 ease-out',
'motion-reduce:transition-none',
className
)}
style={{
transform: `${getTooltipTranslate(state, offset)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
translate: getTooltipTranslate(state, offset),
scale: `${state.scaleX} ${state.scaleY}`,
transform: `skew(${state.skew}deg)`,
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
}}
>
@@ -268,25 +329,31 @@ function getTooltipPosition(
clientY: number
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
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,
}
}
/**
* 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}`
}
/**