rafGroup (one plan, many targets)
Drives N targets from one animation tree with a single clock subscription, one plan compilation and shared evaluation.
rafGroup plays one animation tree on N targets (a list of cards, a grid of tiles). Where N rafTimelines cost N clock subscriptions, N plan compilations and N status machines, a group costs one of each. Targets can start at staggered offsets.
Example
import * as a from "@freestylejs/ani-core";
const cards = a.rafGroup(
a.ani({ to: { opacity: 1, translateY: 0 }, duration: 0.35 }),
{ stagger: (i) => Math.min(i, 20) * 0.03 }
);
cards.bind(document.querySelectorAll<HTMLElement>(".card"));
cards.play({ from: { opacity: 0, translateY: 12 } });
// when the owner goes away
// cards.dispose();Usage & Concepts
Offsets
Target i starts offset_i seconds after the group starts and evaluates the plan at groupTime - offset_i, clamped to [0, planDuration]. Before its offset a target shows its from.
stagger: 0.1gives targetithe offseti * 0.1.stagger: (index, count) => secondscomputes any offset, for example(i, n) => (n - 1 - i) * 0.1for a reverse order.durationis the plan duration plus the largest offset: one run of the whole group.- Offsets must be finite and
>= 0. The function result is validated per index whenplay()is called.
Shared evaluation
Targets with the same offset and the same from values (and the same start velocity) share one evaluation. With stagger: 0 and a constant from, N targets cost one evaluation per frame plus N writes. A from function that returns the same values for many indices keeps them shared.
Playing
import * as a from "@freestylejs/ani-core";
const group = a.rafGroup(
a.ani({ to: { opacity: 1, translateY: 0 }, duration: 0.4 }),
{ stagger: 0.05 }
);
group.play({
from: (index) => ({ opacity: 0, translateY: index * 4 }), // or a constant
count: 20, // default: length of array bindings (bindAt never sets it)
repeat: 1, // repeats the whole group run, offsets included
delay: 0.2, // SECONDS
velocity: { translateY: 0 },
});countdefaults to the length of live array bindings (bind(targets));bindAtbindings never set the count. It is required (a positive integer) when no array of targets is bound.delayis in seconds (finite and>= 0), like onrafTimeline.play()returns a promise that resolves exactly once per run with aRunResult:'ended'when the last target finished (after repeats),'interrupted'when anotherplay()replaces the run,'reset'onreset(),'disposed'ondispose().play()over aPLAYINGgroup starts a new run from the config'sfrom(there is no implicit current value). Each target inherits its own current velocity unlessvelocityis given, so groups of springs retarget without a kink.- Malformed input throws synchronously and leaves the group untouched: no targets and no
count, acountthat is not a positive integer, a negative or non-finitedelayor offset, afromwhose shape does not match the tree or the first target, non-finite values,keyframes/durationsof the wrong length.
Lifecycle
| Status | Meaning | Entered by |
|---|---|---|
IDLE | No run and no value. | Creation, reset() |
PLAYING | Subscribed to the clock until the last target finished. | play(), resume() |
PAUSED | Holding its values. | pause(), seek() |
ENDED | The last target finished (after repeats). Notified once per run. | Natural end |
seek(time)clamps to[0, duration], pauses the group at that group time and notifies once. It does nothing before the firstplay().reset()returns toIDLEand drops every value (countis0again).dispose()leaves the clock, drops bindings and listeners and is idempotent.
Binding elements
bind(targets, options?) writes element i of targets (an array or a NodeList) with target i.
import * as a from "@freestylejs/ani-core";
const group = a.rafGroup(
a.ani({ to: { opacity: 1, translateY: 0 }, duration: 0.4 })
);
const elements = document.querySelectorAll<HTMLElement>(".card");
const unbind = group.bind(elements, {
resolver: { translateY: (v) => ({ key: "top", value: `${v}px` }) },
});- Every element has its own style writer: only declarations whose value changed since that element's previous write are written, in the clock's commit phase.
- The style object is computed once per distinct evaluation and frame and shared by the targets that use it.
- Elements beyond
countare left alone. Missing elements are simply not written. - Binding after a value exists writes immediately.
unbindstops the writes and keeps the styles.dispose()drops all bindings. - An element without a
styleobject throws aTypeErrorsynchronously. Positional (array) values cannot be styled: the first write reports aTypeErrorasynchronously and drops the binding. A failing write is isolated per element.
Framework bindings attach list items one element at a time (e.g. per-item refs mounting at different times) using bindAt:
import * as a from "@freestylejs/ani-core";
const group = a.rafGroup(
a.ani({ to: { opacity: 1, translateY: 0 }, duration: 0.4 })
);
const index = 0;
const element = document.createElement("li");
// Bind a single item element to target index
const unbindItem = group.bindAt(index, element, {
resolver: { translateY: (v) => ({ key: "top", value: `${v}px` }) },
});bindAt(index, target, options?)behaves likebindfor targetindex: diffing writer, writes in commit phase, writes immediately if a value exists, idempotent unbind.bindAtdoes not providecount:play()still requirescountwhen nothing is bound throughbind().
Listening
import * as a from "@freestylejs/ani-core";
const group = a.rafGroup(
a.ani({ to: { opacity: 1, translateY: 0 }, duration: 0.4 })
);
const off = group.onUpdate(({ states, status }) => {
// one frozen state per target
});
const third = group.valueAt(2); // frozen snapshot, null out of range- Listeners run once per frame in the commit phase, after the bound styles are written, and synchronously on
play(),seek()andreset(). All listeners of one notification receive the samestatesarray. - Building
statesis skipped when there are no listeners. - A listener that resets or replays the group stops the remaining notifications of that frame. Listener errors are isolated and rethrown asynchronously.
Reduced motion
rafGroup takes reducedMotion?: ReducedMotionPolicy (default 'jump'). The preference is read from the clock once at the start of each play():
'jump'(default): finishes the whole group run (every target, stagger included) in one frame at its final values, notifies once withENDED, and resolves'ended'. An endless group pauses at the end of its first iteration withPAUSED.{ maxDuration }: compresses the whole group run, stagger included, to at mostmaxDurationseconds by scaling its time.'ignore': animates normally.
See Reduced motion.
API Reference
function rafGroup<G extends Groupable>(
node: AnimationNode<G>,
options?: {
clock?: AnimationClockInterface;
stagger?: number | ((index: number, count: number) => number);
reducedMotion?: ReducedMotionPolicy; // 'jump' | 'ignore' | { maxDuration: number }
}
): RafGroup<G>;
interface RafGroupPlayConfig<G extends Groupable> {
from: G | ((index: number) => G);
count?: number;
keyframes?: Array<G | "keep">;
durations?: Array<number | "keep">;
repeat?: number;
delay?: number; // seconds
velocity?: Partial<G>;
}
interface RafGroup<G extends Groupable> {
readonly status: TimelineStatus;
readonly duration: number; // plan duration + largest offset
readonly count: number; // targets of the current run, 0 before play
play(config: RafGroupPlayConfig<G>): Promise<RunResult>;
pause(): void;
resume(): void;
seek(time: number): void;
reset(): void;
dispose(): void;
valueAt(index: number): Readonly<AniGroup<G>> | null;
bind(
targets: ArrayLike<{ style: CSSStyleDeclaration }>,
options?: { resolver?: Resolver<Record<string, number>> }
): () => void;
bindAt(
index: number,
target: { style: CSSStyleDeclaration } | HTMLElement,
options?: { resolver?: Resolver<Record<string, number>> }
): () => void;
onUpdate(
listener: (frame: {
states: ReadonlyArray<Readonly<AniGroup<G>>>;
status: TimelineStatus;
}) => void
): () => void;
}Related Components
rafTimeline- One animation, one target; same engine and clock phases.stagger- A node that offsets children inside one tree;rafGroupoffsets whole targets.clock- The frame loop the group subscribes to once.
waapiTimeline (Web Animations API)
The controller that runs an animation tree with the browser's native Web Animations API, manages its state and controls its playback.
follow (a value that chases a target)
A spring-driven value that keeps its velocity when the target changes, so repeated input builds on where the motion is heading.