Ani
Guides

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.

EXPERIMENT 01IMMUTABLE DESCRIPTION
36 TARGETSRAF GROUP

One description. Two engines. Zero rewrites.

RAF group engine, playing

01DESCRIPTION

1 tree

2 ani · 1 sequence

02TIME

1 clock

read → update → commit

03REALIZATION

36 targets

one bind · one commit

What the toggle actually proves

The same tree is handed to two different execution substrates:

rafGroupwaapiTimeline
Runs inJavaScript frame loopbrowser compositor
Timeone shared clocknative timeline
Targetsone plan, N style writersN native animations
Same treeyesyes
Same from / staggeryesyes
Switch costdispose + bindcompile + 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. tree is an immutable sequence of two ani nodes. 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. Same from in, 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

  1. Replay seeds a fresh from(index) scatter. The tree is not rebuilt.
  2. 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.
  3. 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.

  • rafGroup — one plan, N targets
  • timeline — the shared timeline representation
  • clock — one frame loop, read → update → commit
  • timing — the easing used above