Skip to content

Commit c2ccb46

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

1 file changed

Lines changed: 23 additions & 63 deletions

File tree

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

Lines changed: 23 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,20 @@ 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
1211

1312
/**
14-
* Resolved position and motion of a floating tooltip. `x`/`y` are viewport
13+
* Resolved position of a floating tooltip. `x`/`y` are whole-pixel viewport
1514
* coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away
16-
* from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive
17-
* flourish while the pointer is moving.
15+
* from the nearest viewport edge.
1816
*/
1917
export interface FloatingTooltipState {
2018
visible: boolean
2119
x: number
2220
y: number
23-
skew: number
24-
scaleX: number
25-
scaleY: number
2621
alignX: 'left' | 'right'
2722
alignY: 'above' | 'below'
2823
}
2924

30-
interface PointerSnapshot {
31-
x: number
32-
y: number
33-
time: number
34-
}
35-
3625
/**
3726
* Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread
3827
* onto the element that should reveal the tooltip on hover or focus.
@@ -50,9 +39,6 @@ const HIDDEN_STATE: FloatingTooltipState = {
5039
visible: false,
5140
x: 0,
5241
y: 0,
53-
skew: 0,
54-
scaleX: 1,
55-
scaleY: 1,
5642
alignX: 'left',
5743
alignY: 'below',
5844
}
@@ -71,48 +57,32 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
7157
const canShowRef = React.useRef(canShow)
7258
canShowRef.current = canShow
7359

74-
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
7560
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
7661

7762
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
78-
const hide = () => {
79-
lastPointerRef.current = null
80-
setState((current) => (current.visible ? HIDDEN_STATE : current))
81-
}
82-
83-
const showStatic = (clientX: number, clientY: number) => {
84-
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
85-
setState({
86-
visible: true,
87-
...getTooltipPosition(clientX, clientY),
88-
skew: 0,
89-
scaleX: 1,
90-
scaleY: 1,
91-
})
63+
const hide = () => setState((current) => (current.visible ? HIDDEN_STATE : current))
64+
65+
const show = (clientX: number, clientY: number) => {
66+
const next = getTooltipPosition(clientX, clientY)
67+
setState((current) =>
68+
current.visible &&
69+
current.x === next.x &&
70+
current.y === next.y &&
71+
current.alignX === next.alignX &&
72+
current.alignY === next.alignY
73+
? current
74+
: { visible: true, ...next }
75+
)
9276
}
9377

9478
return {
9579
onPointerEnter: (event) => {
9680
if (!canShowRef.current(event.currentTarget)) return
97-
showStatic(event.clientX, event.clientY)
81+
show(event.clientX, event.clientY)
9882
},
9983
onPointerMove: (event) => {
10084
if (!canShowRef.current(event.currentTarget)) return
101-
const now = performance.now()
102-
const previous = lastPointerRef.current
103-
const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS
104-
const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0
105-
const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0
106-
const velocity = Math.hypot(velocityX, velocityY)
107-
108-
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
109-
setState({
110-
visible: true,
111-
...getTooltipPosition(event.clientX, event.clientY),
112-
skew: clamp(velocityX * 0.11, -6, 6),
113-
scaleX: 1 + Math.min(0.035, velocity / 1100),
114-
scaleY: 1 - Math.min(0.02, velocity / 1500),
115-
})
85+
show(event.clientX, event.clientY)
11686
},
11787
onPointerLeave: hide,
11888
onPointerDown: hide,
@@ -121,14 +91,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
12191
if (!canShowRef.current(target)) return
12292
if (!isFocusVisible(target)) return
12393
const rect = target.getBoundingClientRect()
124-
lastPointerRef.current = null
125-
setState({
126-
visible: true,
127-
...getTooltipPosition(rect.left + rect.width / 2, rect.bottom),
128-
skew: 0,
129-
scaleX: 1,
130-
scaleY: 1,
131-
})
94+
show(rect.left + rect.width / 2, rect.bottom)
13295
},
13396
onBlur: hide,
13497
}
@@ -248,14 +211,11 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({
248211
aria-hidden={role ? undefined : 'true'}
249212
data-native-surface-overlay=''
250213
className={cn(
251-
'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',
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',
252215
'motion-reduce:transition-none',
253216
className
254217
)}
255-
style={{
256-
transform: `${getTooltipTranslate(state, offset)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
257-
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
258-
}}
218+
style={{ transform: getTooltipTranslate(state, offset) }}
259219
>
260220
{children ?? <span className='block whitespace-normal break-words text-left'>{label}</span>}
261221
</div>,
@@ -268,15 +228,15 @@ function getTooltipPosition(
268228
clientY: number
269229
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
270230
if (typeof window === 'undefined') {
271-
return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' }
231+
return { x: Math.round(clientX), y: Math.round(clientY), alignX: 'left', alignY: 'below' }
272232
}
273233

274234
const alignX = window.innerWidth - clientX < EDGE_THRESHOLD ? 'right' : 'left'
275235
const alignY = window.innerHeight - clientY < EDGE_THRESHOLD / 2 ? 'above' : 'below'
276236

277237
return {
278-
x: clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER),
279-
y: clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER),
238+
x: Math.round(clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER)),
239+
y: Math.round(clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER)),
280240
alignX,
281241
alignY,
282242
}

0 commit comments

Comments
 (0)