tempus means time in Latin, this package is a lightweight, high-performance animation frame manager for JavaScript applications.
- tempus — core loop
- tempus/react — React bindings (
useTempus,ReactTempus) - tempus/profiler — live frame-budget overlay
- 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 control —
play(),pause()andrestart()the whole loop at once - rAF patching —
patch()absorbs every nativerequestAnimationFrame(including third-party and minified loops) into the shared loop - Live profiler overlay —
tempus/profilerdraws a real-time timeline of how each callback fills the frame budget - Introspection —
Tempus.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
using package manager
npm install tempusimport Tempus from 'tempus'using script tag
<script src="https://unpkg.com/tempus@1.0.0-dev.17/dist/tempus.min.js"></script> 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)const unsubscribe = Tempus.add(animate)
unsubscribe()Tempus.pause() // no rafs will be called
Tempus.play() // resume
Tempus.restart() // set clock elapsed time to 0See tempus/react
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 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
// 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 })scroll
animate
render
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 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')
}
})// 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.
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' })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.
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' }, ...]// lenis.raf expects a time in ms, so pull it off the state object
Tempus.add(({ time }) => lenis.raf(time))// Remove GSAP's internal RAF
gsap.ticker.remove(gsap.updateRoot)
// Add to Tempus
Tempus.add(({ time }) => {
gsap.updateRoot(time / 1000)
})Tempus.add(() => {
renderer.render(scene, camera)
}, { order: 1 })
// the render will happen after other rafs
// so it can be synched with lenis for instanceAdds an animation callback to the loop.
- callback:
(state: TempusState) => void, whereTempusStateis:time:number- Elapsed time in ms since the loop starteddeltaTime:number- Time in ms since this callback's previous runframe:number- Frame counterbudget:() => 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 CSSorder)priority:number- Deprecated alias fororderfps: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 ratelabel:string- Optional name shown inTempus.inspect()and the profiler overlay
- Returns:
() => void- Unsubscribe function
Starts (or resumes) the loop. The loop auto-starts on the client when Tempus is imported.
Stops the loop; no callbacks run until play() is called.
Resets the clock's elapsed time to 0 and resumes the loop.
boolean - Whether the loop is currently running.
number (default: 60). The frame rate state.budget() is measured against — the budget per frame is 1000 / Tempus.targetFps ms.
number - The live measured frame rate (1000 / deltaTime) of the most recent frame.
number - Fraction of the last frame's delta spent inside Tempus callbacks (duration / deltaTime).
Returns a TempusCallbackInfo[] timing snapshot of every active callback (both Tempus.add() callbacks and loops absorbed by patch()):
label:stringsamples:number[]- recent per-frame durations in msorder:numberfps:number | stringsource:'add' | 'patch'
Patches the native requestAnimationFrame to route every call through Tempus's single loop.
Restores the original native requestAnimationFrame and cancelAnimationFrame.
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 panelfps:number(default:5) - overlay refresh ratecontainer:HTMLElement(default:document.body) - mount target
- 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
MIT © darkroom.engineering
Thank you to Keith Cirkel for having transfered us the npm package name 🙏.
