One tree. Every target. Either engine.
A live 36-tile kinetic field — one immutable tree, two realizers, zero rewrites.
TL;DR
One description. Thirty-six targets. Two engines. Toggle RAF GROUP
and WAAPI: the tree, the stagger and the per-target from stay
identical. Only the realizer changes. No rewrite, no second choreography,
no engine-shaped API.
One description. Two engines. Zero rewrites.
RAF group engine, playing
1 tree
2 ani · 1 sequence
1 clock
read → update → commit
36 targets
one bind · one commit
What the toggle actually proves
The same tree is handed to two different execution substrates:
rafGroup | waapiTimeline | |
|---|---|---|
| Runs in | JavaScript frame loop | browser compositor |
| Time | one shared clock | native timeline |
| Targets | one plan, N style writers | N native animations |
| Same tree | yes | yes |
Same from / stagger | yes | yes |
| Switch cost | dispose + bind | compile + play |
That is the V2 claim in miniature: the description is the unit of work, not the element and not the engine. The toggle is deliberately boring to watch; the tiles land in the same places because the semantics are the same.
The shape of the win
The old shape is element-first: one element, one timeline, one subscription. The V2 shape is description-first:
- const timelines = tiles.map((tile) => {
- const timeline = a.rafTimeline(tree)
- timeline.bind(tile)
- void timeline.play({ from })
- return timeline
- })
+ const group = a.rafGroup(tree, { stagger })
+ group.bind(tiles)
+ void group.play({ from: (i) => scatter(i) })Measured, not vibes
In a representative project browser benchmark at N=5,000
(transform + opacity): one rafGroup measured 4.1 ms p95 versus
11.0 ms for one rafTimeline per element. The structural difference is
the point: one plan and one clock subscription instead of 5,000. Absolute
timings depend on the browser and hardware; run
pnpm bench:check to measure your target
environment.
Read it in three layers
- Description.
treeis an immutablesequenceof twoaninodes. It contains no DOM, no values and no time. It can be replayed forever, on any number of targets. - Time. The RAF realizer subscribes to the shared clock once and evaluates each target at its own local time. WAAPI compiles the same tree to native keyframes; the browser's timeline drives them.
- Realization.
group.bind(tiles)creates one writer per tile and commits only changed declarations.waapi.play(tiles, …)receives the same target list and the same stagger. Samefromin, same end state out.
The important part is what does not change: tree, stagger, and
from(index) are shared by both engines. The realizer is a deployment target.
Try it
- Replay seeds a fresh
from(index)scatter. The tree is not rebuilt. - Switch engines mid-run. The new realizer starts a new run from the same tree and the same scatter; the playhead resets, the choreography does not.
- Pause / resume on either engine. Transport semantics are part of the timeline contract, not the engine.
The point
Most animation code makes the element the unit of work. Ani makes the description the unit of work and lets realization fan out. The same tree can drive one element, 36 tiles, or 5,000 rows; the cost model is properties and distinct target states, not the number of segments in the tree.
This is not a benchmark and not a promise that every target is one evaluation:
with a stagger, each distinct (offset, from) pair gets its own local time.
What is shared is the plan, the clock subscription, the binding machinery, and
the semantics. That is enough to stop writing the same choreography once per
element.
import * as a from '@freestylejs/ani-core'
type Tile = {
opacity: number
scale: number
translateX: number
translateY: number
rotate: number
}
const COLS = 12
const TILE_COUNT = 36
const tree = a.sequence([
a.ani({
to: { opacity: 1, scale: 1.06, translateX: 0, translateY: 0, rotate: 0 },
duration: 0.8,
timing: a.timing.easeOut(),
}),
a.ani({
to: { opacity: 1, scale: 1, translateX: 0, translateY: 0, rotate: 0 },
duration: 0.45,
timing: a.timing.easeInOut(),
}),
])
const stagger = (index: number) =>
(index % COLS) * 0.022 + Math.floor(index / COLS) * 0.05
const TAU = Math.PI * 2
const noise = (index: number, seed: number): number => {
const value = Math.sin(index * 12.9898 + seed * 78.233) * 43758.5453
return value - Math.floor(value)
}
const fromFor = (index: number, seed: number): Tile => {
const col = index % COLS
const row = Math.floor(index / COLS)
const angle = noise(index, seed) * TAU
const radius = 24 + noise(index + 101, seed) * 92
return {
opacity: 0,
scale: 0.35 + noise(index + 211, seed) * 0.3,
translateX: (col - (COLS - 1) / 2) * 6 + Math.cos(angle) * radius,
translateY: (row - 1) * 8 + Math.sin(angle) * radius * 0.7,
rotate: (noise(index + 307, seed) - 0.5) * 84,
}
}
const tiles = Array.from({ length: TILE_COUNT }, () =>
document.createElement('div')
)
// One tree, realized by the shared RAF clock.
const group = a.rafGroup(tree, { stagger })
const unbind = group.bind(tiles)
void group.play({ from: (index) => fromFor(index, 1) })
// ...or hand the same tree to the browser's timeline instead.
unbind()
group.dispose()
const waapi = a.waapiTimeline(tree)
void waapi.play(tiles, {
from: (index) => fromFor(index, 1),
stagger,
effect: { fill: 'both' }, // hold `from` during the stagger delay
})
waapi.dispose()The engine is not the architecture. The tree is.