Skip to content

Commit fc4a679

Browse files
committed
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.
1 parent c2ccb46 commit fc4a679

1 file changed

Lines changed: 100 additions & 15 deletions

File tree

packages/emcn/src/components/tooltip/tooltip.tsx

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,49 @@ import { cn } from '../../lib/cn'
88
const TOOLTIP_OFFSET = 16
99
const EDGE_GUTTER = 16
1010
const EDGE_THRESHOLD = 360
11+
const MIN_FRAME_MS = 16
1112

1213
/**
13-
* Resolved position of a floating tooltip. `x`/`y` are whole-pixel viewport
14-
* coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away
15-
* from the nearest viewport edge.
14+
* How much of the gap between the smoothed and the instantaneous pointer velocity
15+
* is closed per pointer event. This is what softens the velocity flourish — the
16+
* transform itself is never handed to a CSS transition, because a compositor-
17+
* interpolated fractional scale forces the tooltip's rasterized text to be
18+
* resampled, which reads as a blur until the interpolation settles.
19+
*/
20+
const VELOCITY_SMOOTHING = 0.35
21+
22+
/**
23+
* Resolved position and motion of a floating tooltip. `x`/`y` are whole-pixel
24+
* viewport coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip
25+
* away from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive
26+
* flourish while the pointer is moving.
1627
*/
1728
export interface FloatingTooltipState {
1829
visible: boolean
1930
x: number
2031
y: number
32+
skew: number
33+
scaleX: number
34+
scaleY: number
2135
alignX: 'left' | 'right'
2236
alignY: 'above' | 'below'
2337
}
2438

39+
/** Velocity-derived flourish applied to the tooltip on a given frame. */
40+
interface TooltipMotion {
41+
skew: number
42+
scaleX: number
43+
scaleY: number
44+
}
45+
46+
const NEUTRAL_MOTION: TooltipMotion = { skew: 0, scaleX: 1, scaleY: 1 }
47+
48+
interface PointerSnapshot {
49+
x: number
50+
y: number
51+
time: number
52+
}
53+
2554
/**
2655
* Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread
2756
* onto the element that should reveal the tooltip on hover or focus.
@@ -39,6 +68,7 @@ const HIDDEN_STATE: FloatingTooltipState = {
3968
visible: false,
4069
x: 0,
4170
y: 0,
71+
...NEUTRAL_MOTION,
4272
alignX: 'left',
4373
alignY: 'below',
4474
}
@@ -57,32 +87,68 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
5787
const canShowRef = React.useRef(canShow)
5888
canShowRef.current = canShow
5989

90+
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
91+
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
6092
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
6193

6294
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
63-
const hide = () => setState((current) => (current.visible ? HIDDEN_STATE : current))
95+
const reset = () => {
96+
lastPointerRef.current = null
97+
velocityRef.current.x = 0
98+
velocityRef.current.magnitude = 0
99+
}
64100

65-
const show = (clientX: number, clientY: number) => {
66-
const next = getTooltipPosition(clientX, clientY)
101+
const hide = () => {
102+
reset()
103+
setState((current) => (current.visible ? HIDDEN_STATE : current))
104+
}
105+
106+
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
107+
const next = { ...getTooltipPosition(clientX, clientY), ...motion }
67108
setState((current) =>
68109
current.visible &&
69110
current.x === next.x &&
70111
current.y === next.y &&
71112
current.alignX === next.alignX &&
72-
current.alignY === next.alignY
113+
current.alignY === next.alignY &&
114+
current.skew === next.skew &&
115+
current.scaleX === next.scaleX &&
116+
current.scaleY === next.scaleY
73117
? current
74118
: { visible: true, ...next }
75119
)
76120
}
77121

122+
const showNeutral = (clientX: number, clientY: number) => {
123+
reset()
124+
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
125+
apply(clientX, clientY, NEUTRAL_MOTION)
126+
}
127+
78128
return {
79129
onPointerEnter: (event) => {
80130
if (!canShowRef.current(event.currentTarget)) return
81-
show(event.clientX, event.clientY)
131+
showNeutral(event.clientX, event.clientY)
82132
},
83133
onPointerMove: (event) => {
84134
if (!canShowRef.current(event.currentTarget)) return
85-
show(event.clientX, event.clientY)
135+
const now = performance.now()
136+
const previous = lastPointerRef.current
137+
const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS
138+
const instantX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0
139+
const instantY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0
140+
141+
const velocity = velocityRef.current
142+
velocity.x += (instantX - velocity.x) * VELOCITY_SMOOTHING
143+
velocity.magnitude +=
144+
(Math.hypot(instantX, instantY) - velocity.magnitude) * VELOCITY_SMOOTHING
145+
146+
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
147+
apply(event.clientX, event.clientY, {
148+
skew: quantize(clamp(velocity.x * 0.11, -6, 6)),
149+
scaleX: quantize(1 + Math.min(0.035, velocity.magnitude / 1100)),
150+
scaleY: quantize(1 - Math.min(0.02, velocity.magnitude / 1500)),
151+
})
86152
},
87153
onPointerLeave: hide,
88154
onPointerDown: hide,
@@ -91,7 +157,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
91157
if (!canShowRef.current(target)) return
92158
if (!isFocusVisible(target)) return
93159
const rect = target.getBoundingClientRect()
94-
show(rect.left + rect.width / 2, rect.bottom)
160+
showNeutral(rect.left + rect.width / 2, rect.bottom)
95161
},
96162
onBlur: hide,
97163
}
@@ -159,6 +225,14 @@ export function clamp(value: number, min: number, max: number): number {
159225
return Math.max(min, Math.min(max, value))
160226
}
161227

228+
/**
229+
* Rounds a flourish value to 3 decimals so pointer jitter below the visible
230+
* threshold settles to a stable number instead of re-rendering every consumer.
231+
*/
232+
function quantize(value: number): number {
233+
return Math.round(value * 1000) / 1000
234+
}
235+
162236
/**
163237
* Whether an element currently matches `:focus-visible` (keyboard focus, not focus produced by a
164238
* 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({
211285
aria-hidden={role ? undefined : 'true'}
212286
data-native-surface-overlay=''
213287
className={cn(
214-
'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',
288+
'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',
215289
'motion-reduce:transition-none',
216290
className
217291
)}
218-
style={{ transform: getTooltipTranslate(state, offset) }}
292+
style={{
293+
translate: getTooltipTranslate(state, offset),
294+
scale: `${state.scaleX} ${state.scaleY}`,
295+
transform: `skew(${state.skew}deg)`,
296+
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
297+
}}
219298
>
220299
{children ?? <span className='block whitespace-normal break-words text-left'>{label}</span>}
221300
</div>,
@@ -242,11 +321,17 @@ function getTooltipPosition(
242321
}
243322
}
244323

324+
/**
325+
* Value for the `translate` CSS property. Kept off the `transform` property so the
326+
* velocity flourish (`scale` + `transform: skew()`) can stay out of the transition
327+
* list while the tooltip's position still eases toward the cursor.
328+
*/
245329
function getTooltipTranslate(state: FloatingTooltipState, offset: number): string {
246-
const xOffset = state.alignX === 'left' ? `${offset}px` : `calc(-100% - ${offset}px)`
247-
const yOffset = state.alignY === 'below' ? `${offset}px` : `calc(-100% - ${offset}px)`
330+
const x = state.alignX === 'left' ? `${state.x + offset}px` : `calc(${state.x - offset}px - 100%)`
331+
const y =
332+
state.alignY === 'below' ? `${state.y + offset}px` : `calc(${state.y - offset}px - 100%)`
248333

249-
return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})`
334+
return `${x} ${y}`
250335
}
251336

252337
/**

0 commit comments

Comments
 (0)