Skip to content

darkroomengineering/tempus

Repository files navigation

Tempus

TEMPUS

Introduction

tempus means time in Latin, this package is a lightweight, high-performance animation frame manager for JavaScript applications.

Packages

Features

  • One shared rAF loop — merges every requestAnimationFrame call into a single loop to cut per-frame overhead
  • Explicit ordering — run animations in an explicit order each frame instead of registration order
  • Custom frame rates — throttle callbacks to a target FPS (absolute like 30, or relative like '50%') independent of the display refresh
  • Frame budget — every callback gets state.budget() (ms left this frame) to gracefully skip or chunk work
  • Playback controlplay(), pause() and restart() the whole loop at once
  • rAF patchingpatch() absorbs every native requestAnimationFrame (including third-party and minified loops) into the shared loop
  • Live profiler overlaytempus/profiler draws a real-time timeline of how each callback fills the frame budget
  • IntrospectionTempus.inspect() exposes per-callback timing for added and patched loops alike
  • Library-friendly — drop-in compatible with GSAP, Lenis, and other animation tools
  • Zero dependencies — no external packages, nothing extra to audit
  • ~1KB gzipped — a negligible footprint for a core primitive

Installation

using package manager

npm install tempus
import Tempus from 'tempus'

using script tag

<script src="https://unpkg.com/tempus@1.0.0-dev.17/dist/tempus.min.js"></script> 

Basic Usage

import Tempus from "tempus"

// Simple animation at maximum FPS.
// Every callback receives a single `state` object:
// { time, deltaTime, frame, budget }
function animate({ time, deltaTime, frame, budget }) {
  console.log('frame', time, deltaTime)
}

Tempus.add(animate)

Cleanup

const unsubscribe = Tempus.add(animate)

unsubscribe()

Playback Control

Tempus.pause() // no rafs will be called
Tempus.play() // resume
Tempus.restart() // set clock elapsed time to 0

React

See tempus/react

Advanced Usage

Custom Frame Rates

Tempus.add(animate, { 
  fps: 30 // Will run at 30 FPS
})

Tempus.add(animate, { 
  fps: '50%' // Will run at 50% of the system's FPS
})

Order System

order is a sort key for execution within a frame — lower runs first, exactly like CSS order. Default is 0; negative values run before it, positive after.

——[-Infinity]——[0]——[Infinity]——> execution order

Input

// Default order: 0 (runs second)
Tempus.add(() => console.log('animate'))

// Order: 1 (runs third)
Tempus.add(() => console.log('render'), { order: 1 })

// Order: -1 (runs first)
Tempus.add(() => console.log('scroll'), { order: -1 })

Output

scroll
animate
render

Idle Pattern (frame budget)

state.budget() returns the milliseconds left in the current frame before it exceeds the budget (1000 / Tempus.targetFps, default 60fps ≈ 16.67ms). It's the live equivalent of requestIdleCallback's timeRemaining(), so you can gate optional or expensive work and avoid blocking the main thread:

// run only when there's spare frame time left
Tempus.add(({ budget }) => {
  if (budget() > 0) doExpensiveWork()
})

// or chew through work in chunks until the budget runs out.
// budget() is live, so calling it again inside the loop reflects time already spent.
Tempus.add((state) => {
  while (state.budget() > 0) {
    doChunkOfWork()
  }
})

Tune the target with Tempus.targetFps (default 60). Note this is frame-budget idle — leftover time before the frame is over budget — not the browser's true post-paint idle. For genuine background work, prefer native requestIdleCallback.

Ping Pong Technique

ping and pong will alternate between each frame, but never during the same frame

Tempus.add(({ frame }) => {
  if (frame % 2 === 0) {
    console.log('ping')
  } else {
    console.log('pong')
  }
})

Global RAF Patching

// Patch native requestAnimationFrame across all your app
Tempus.patch()
// Now any requestAnimationFrame recursive calls will use Tempus

// Restore the original requestAnimationFrame when you're done
Tempus.unpatch()

Patching absorbs every native requestAnimationFrame call — including loops inside third-party and minified libraries — into the single shared loop, with no name detection or string matching. Re-registering callbacks run on the next frame (matching native one-shot rAF semantics), and a throwing callback is caught and logged so it can't take down the rest of the frame.

Labelling Callbacks

Give a callback a label so it's easy to identify in the profiler overlay and in Tempus.inspect():

Tempus.add(animate, { label: 'hero-parallax' })

Profiler Overlay

tempus/profiler mounts a live, draggable overlay that visualises how a single frame is composed. It lays every callback — both Tempus.add() callbacks and loops absorbed by Tempus.patch() — end-to-end on a timeline whose full width is the per-frame budget (1000 / Tempus.targetFps), so you can watch the frame fill up and overflow in real time.

import { profiler } from 'tempus/profiler'

const overlay = profiler({
  corner: 'top-left', // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
  fps: 5,             // overlay refresh rate (the measurements it shows are unaffected)
  container: document.body, // mount target
})

// later
overlay.destroy()

The panel shows live FPS and budget usage in its header, a colour-coded timeline (throttled callbacks are hatched, the over-budget region is highlighted in red), and a per-callback legend with its order, target FPS and average cost. Click the header to collapse it, drag it to reposition, or use the play/pause button to start and stop the whole loop. It's SSR-safe — on the server profiler() returns a no-op handle.

Introspection

Tempus.inspect() returns a normalized timing snapshot of every active callback — added and patched alike — which is what powers the profiler overlay:

Tempus.inspect()
// [{ label, samples, order, fps, source: 'add' | 'patch' }, ...]

Integration Examples

With Lenis Smooth Scroll

// lenis.raf expects a time in ms, so pull it off the state object
Tempus.add(({ time }) => lenis.raf(time))

With GSAP

// Remove GSAP's internal RAF
gsap.ticker.remove(gsap.updateRoot)

// Add to Tempus
Tempus.add(({ time }) => {
  gsap.updateRoot(time / 1000)
})

With Three.js

Tempus.add(() => {
  renderer.render(scene, camera)
}, { order: 1 })
// the render will happen after other rafs
// so it can be synched with lenis for instance

API Reference

Tempus.add(callback, options)

Adds an animation callback to the loop.

  • callback: (state: TempusState) => void, where TempusState is:
    • time: number - Elapsed time in ms since the loop started
    • deltaTime: number - Time in ms since this callback's previous run
    • frame: number - Frame counter
    • budget: () => number - Call it for the ms left in the current frame before exceeding the budget (live)
  • options:
    • order: number (default: 0) - Sort key for execution order; lower runs first (like CSS order)
    • priority: number - Deprecated alias for order
    • fps: number | string (default: Infinity) - Target frame rate. A number throttles to that absolute FPS; a string like '50%' runs at a fraction of the system frame rate
    • label: string - Optional name shown in Tempus.inspect() and the profiler overlay
  • Returns: () => void - Unsubscribe function

Tempus.play()

Starts (or resumes) the loop. The loop auto-starts on the client when Tempus is imported.

Tempus.pause()

Stops the loop; no callbacks run until play() is called.

Tempus.restart()

Resets the clock's elapsed time to 0 and resumes the loop.

Tempus.isPlaying

boolean - Whether the loop is currently running.

Tempus.targetFps

number (default: 60). The frame rate state.budget() is measured against — the budget per frame is 1000 / Tempus.targetFps ms.

Tempus.fps

number - The live measured frame rate (1000 / deltaTime) of the most recent frame.

Tempus.usage

number - Fraction of the last frame's delta spent inside Tempus callbacks (duration / deltaTime).

Tempus.inspect()

Returns a TempusCallbackInfo[] timing snapshot of every active callback (both Tempus.add() callbacks and loops absorbed by patch()):

  • label: string
  • samples: number[] - recent per-frame durations in ms
  • order: number
  • fps: number | string
  • source: 'add' | 'patch'

Tempus.patch()

Patches the native requestAnimationFrame to route every call through Tempus's single loop.

Tempus.unpatch()

Restores the original native requestAnimationFrame and cancelAnimationFrame.

profiler(options)

import { profiler } from 'tempus/profiler' — mounts the live frame-budget overlay and returns a { element, destroy } handle.

  • options:
    • corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' (default: 'top-left') - where to pin the panel
    • fps: number (default: 5) - overlay refresh rate
    • container: HTMLElement (default: document.body) - mount target

Best Practices

  • Order callbacks deliberately: things others depend on (like scroll) should run first — give them a lower order (e.g. -1)
  • Clean up animations when they're no longer needed
  • Consider using specific FPS for non-critical animations to improve performance (e.g: collisions)
  • Gate optional or expensive work on state.budget() so it yields when the frame is full
  • Use Ping Pong technique for heavy computations running concurrently

License

MIT © darkroom.engineering

Shoutout

Thank you to Keith Cirkel for having transfered us the npm package name 🙏.

About

Use only one requestAnimationFrame for your whole app

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors