@@ -8,20 +8,49 @@ import { cn } from '../../lib/cn'
88const TOOLTIP_OFFSET = 16
99const EDGE_GUTTER = 16
1010const 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 */
1728export 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+ */
245329function 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