tempus
Version:
one rAF to rule them all
320 lines (222 loc) • 10.6 kB
Markdown
# Tempus
[](https://github.com/darkroomengineering/tempus)
## Introduction
`tempus` means time in Latin, this package is a lightweight, high-performance animation frame manager for JavaScript applications.
## Packages
- [tempus](./README.md) — core loop
- [tempus/react](./packages/react/README.md) — React bindings (`useTempus`, `ReactTempus`)
- [tempus/profiler](#profiler-overlay) — live frame-budget overlay
## 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 control** — `play()`, `pause()` and `restart()` the whole loop at once
- **rAF patching** — `patch()` absorbs every native `requestAnimationFrame` (including third-party and minified loops) into the shared loop
- **Live profiler overlay** — `tempus/profiler` draws 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
## Installation
using package manager
```bash
npm install tempus
```
```js
import Tempus from 'tempus'
```
using script tag
```html
<script src="https://unpkg.com/tempus@1.0.0-dev.17/dist/tempus.min.js"></script>
```
## Basic Usage
```javascript
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
```javascript
const unsubscribe = Tempus.add(animate)
unsubscribe()
```
### Playback Control
```javascript
Tempus.pause() // no rafs will be called
Tempus.play() // resume
Tempus.restart() // set clock elapsed time to 0
```
### React
See [tempus/react](./packages/react/README.md)
## Advanced Usage
### Custom Frame Rates
```javascript
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
```javascript
// 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:
```javascript
// 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
```javascript
Tempus.add(({ frame }) => {
if (frame % 2 === 0) {
console.log('ping')
} else {
console.log('pong')
}
})
```
### Global RAF Patching
```javascript
// 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](#profiler-overlay) and in `Tempus.inspect()`:
```javascript
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.
```javascript
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:
```javascript
Tempus.inspect()
// [{ label, samples, order, fps, source: 'add' | 'patch' }, ...]
```
## Integration Examples
### With Lenis Smooth Scroll
```javascript
// lenis.raf expects a time in ms, so pull it off the state object
Tempus.add(({ time }) => lenis.raf(time))
```
### With GSAP
```javascript
// Remove GSAP's internal RAF
gsap.ticker.remove(gsap.updateRoot)
// Add to Tempus
Tempus.add(({ time }) => {
gsap.updateRoot(time / 1000)
})
```
### With Three.js
```javascript
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](https://github.com/darkroomengineering)
# Shoutout
Thank you to [Keith Cirkel](https://github.com/keithamus) for having transfered us the npm package name 🙏.