/ ink / hooks / use-animation-frame.ts
use-animation-frame.ts
 1  import { useContext, useEffect, useState } from 'react'
 2  import { ClockContext } from '../components/ClockContext.js'
 3  import type { DOMElement } from '../dom.js'
 4  import { useTerminalViewport } from './use-terminal-viewport.js'
 5  
 6  /**
 7   * Hook for synchronized animations that pause when offscreen.
 8   *
 9   * Returns a ref to attach to the animated element and the current animation time.
10   * All instances share the same clock, so animations stay in sync.
11   * The clock only runs when at least one keepAlive subscriber exists.
12   *
13   * Pass `null` to pause — unsubscribes from the clock so no ticks fire.
14   * Time freezes at the last value and resumes from the current clock time
15   * when a number is passed again.
16   *
17   * @param intervalMs - How often to update, or null to pause
18   * @returns [ref, time] - Ref to attach to element, elapsed time in ms
19   *
20   * @example
21   * function Spinner() {
22   *   const [ref, time] = useAnimationFrame(120)
23   *   const frame = Math.floor(time / 120) % FRAMES.length
24   *   return <Box ref={ref}>{FRAMES[frame]}</Box>
25   * }
26   *
27   * The clock automatically slows when the terminal is blurred,
28   * so consumers don't need to handle focus state.
29   */
30  export function useAnimationFrame(
31    intervalMs: number | null = 16,
32  ): [ref: (element: DOMElement | null) => void, time: number] {
33    const clock = useContext(ClockContext)
34    const [viewportRef, { isVisible }] = useTerminalViewport()
35    const [time, setTime] = useState(() => clock?.now() ?? 0)
36  
37    const active = isVisible && intervalMs !== null
38  
39    useEffect(() => {
40      if (!clock || !active) return
41  
42      let lastUpdate = clock.now()
43  
44      const onChange = (): void => {
45        const now = clock.now()
46        if (now - lastUpdate >= intervalMs!) {
47          lastUpdate = now
48          setTime(now)
49        }
50      }
51  
52      // keepAlive: true — visible animations drive the clock
53      return clock.subscribe(onChange, true)
54    }, [clock, intervalMs, active])
55  
56    return [viewportRef, time]
57  }