Springs and motion helpers
spring(), ramp(), mix(), stagger(), posterize(), loop(), hold(), focus(), wiggle() and the seeded random and noise functions return a value for a time or an index without reading the DOM.
A spring is set by stiffness, damping and mass, or by duration and bounce, and settle() returns the seconds it takes to stay within 2%.
stagger(i, n, options) returns item i's delay, for styleAt(this.local - stagger(i, n, options), tracks).
noise(), noise2() and noise3() have no default seed, so a call without one throws (KN-100).
spring(t, options)
spring(t, options) returns the position of a damped spring at t seconds, from rest, in closed form, so any t gives its value without stepping.
| Option | Default | Sets |
|---|---|---|
stiffness | 100 | The spring's pull, above 0. |
damping | 10 | The friction, 0 or more: below 2 * Math.sqrt(stiffness * mass), 20 for the defaults, the spring overshoots. |
mass | 1 | The weight it moves, above 0. |
from | 0 | The value at rest before the spring is released. |
to | 1 | The value it settles at. |
overshootClamping | false | Holds the value between from and to. |
duration | None | Seconds until the spring stays within 2% of the distance from to (KN-123's measure), above 0. |
bounce | 0 | With duration, 0 to below 1: the damping ratio is 1 - bounce, so 0 never overshoots. |
A bad option, stiffness, damping or mass given with duration, or a bounce without a duration throws (KN-100).
A t of 0 or less returns from.
A spring is set by stiffness, damping and mass, or by duration and bounce.
from, to and overshootClamping work the same in both forms.
- With
duration, the mass is 1 and the stiffness is solved for that settling time, plus a few trillionths so the spring is inside 2% at its duration and never a rounding error over. - A spring ease,
{ spring: { duration: 0.6, bounce: 0.2 } }, takes the same options (KN-121 for a bad one).
settle(options) returns the seconds a spring in either form takes to stay within 2% of the distance from from to to, which KN-123's suggestion gives rounded up to a hundredth of a second.
With overshootClamping, an overshoot held at to does not count.
A spring with no damping never settles, and settle() returns Infinity.
const { spring, settle } = wildflower.kinetic;
spring(t, { duration: 0.8, bounce: 0.3 }); // settles 0.8 s after t = 0
settle({ duration: 0.8, bounce: 0.3 }); // 0.7999999999991998
settle({ stiffness: 100, damping: 10 }); // 0.8076...
ramp(t, from, to, ease) and mix(a, b, p, options)
ramp(t, from, to, ease) returns how far t is from from to to seconds, as 0 to 1, clamped at both ends.
The optional ease is anything ease() accepts, and a spring ease reads the seconds since from and runs on past to, as in interpolate.
tis a number, andfromandtoare finite withtoafterfrom, or the call throws (KN-100).- An unknown ease throws (KN-121).
A clip's own fade needs no ramp: see Which to use for fading a clip.
mix(a, b, p) mixes two values of one kind at p, which is not clamped.
- Two numbers give
a + (b - a) * p. - Two colors, in any form
interpolatetakes, mix as ininterpolate, per channel or in oklch withoptions.space, into anrgb()orrgba()string. - Two text values with the same text mix every number and color in them as a text track does, and give the string.
- A
pthat is not a finite number, aspacethat is neither'rgb'nor'oklch', or any other pair throws (KN-100). For two text values, the development build's message gives the first place where they differ.
| Option | Default | Sets |
|---|---|---|
space | 'rgb' | How two colors mix: 'rgb' per channel, or 'oklch'. |
const { ramp, mix } = wildflower.kinetic;
// 0 until 1.2 s, then 0 to 1 by 2 s, eased
const m = ramp(this.local, 1.2, 2, 'power3.inOut');
const x = mix(-300, 0, m); // px
const tint = mix('#4280A2', '#B1A8FF', m); // 'rgb(...)'
Colors in oklch
With space: 'oklch' on a track, a text track with colors, interpolate() or mix(), two colors mix in OKLCh, converted from sRGB through OKLab with Björn Ottosson's published matrices and back.
A per-channel mix of blue and yellow passes through gray, and one in oklch passes through teal.
- Lightness, chroma and alpha mix linearly, and the hue goes the shorter way round the circle.
- A gray has no hue, so it takes the other color's.
- A mix that falls outside sRGB keeps its lightness and hue, and its chroma is reduced until it fits.
- The output is the same
rgb()orrgba()string a color track writes, and the two keyframes come back exactly. - Another
spacethrows (KN-120 on a track orinterpolate(), KN-100 onmix()).
keyframes: {
sky: { background: { at: [0, 3], to: ['#1d3fbf', '#f2c53d'], space: 'oklch' } }
}
wildflower.kinetic.mix('#0000ff', '#ffff00', 0.5); // 'rgb(128, 128, 128)'
wildflower.kinetic.mix('#0000ff', '#ffff00', 0.5, { space: 'oklch' }); // 'rgb(0, 186, 174)'
stagger(i, n, options)
stagger(i, n, options) returns item i's delay in seconds, for styleAt(this.local - stagger(i, n, options), tracks).
| Option | Default | Sets |
|---|---|---|
each | 0.05 | Seconds per step of distance from the origin. 0 or more. |
total | None | Seconds from the first item to start to the last. Replaces each. 0 or more. |
from | 'first' | The origin: 'first', 'last', 'center', 'edges' (the outermost items start first), 'random', or an index from 0 to n - 1. |
grid | None | [columns, rows], with a cell for each item. |
axis | None | With grid, 'x' or 'y': the distance along that axis only. |
ease | 'linear' | How the delays are spread, as anything ease() takes. |
seed | 1 | The order for 'random', a number or a string. |
An i that is not a whole number from 0 to n - 1, an n that is not a whole number from 1 to 1000000, or a bad option throws (KN-100), and an unknown ease KN-121.
The delay comes from the item's distance from an origin, and the item nearest the origin starts at 0.
- An item at fraction
dof the way from the nearest distance to the farthest starts atease(d)times the span, which istotal, oreachtimes the farthest distance past the nearest. - With
'center'and an evenn, the two middle items start at 0. - With one item, or every item at the same distance, the delay is 0.
In a row, an item's distance depends on the origin.
| From | Distance in a row |
|---|---|
'first' | i |
'last' | n - 1 - i |
'center' | |i - (n - 1) / 2| |
'edges' | min(i, n - 1 - i) |
An index k | |i - k| |
'random' | The item's rank in a shuffle of 0 to n - 1, the same for the same seed and n. |
On a grid, item i sits at column i % columns and row floor(i / columns), and its distance is the straight line from the origin's cell, or its part along axis.
'first' and 'last' are the first and last cells, 'center' the grid's center, an index that cell, and 'edges' measures to the grid's nearest edge.
The origin can be a point with no item, such as the center of a grid with an even number of columns or the last cell of a short last row, and the items nearest it start at 0.
const { styleAt, stagger, split } = wildflower.kinetic;
// letters rise from the middle of the word outward, over 0.4 s in all
letters() {
const rows = split('KINETIC');
return rows.map((r) => ({ key: r.key, text: r.text,
style: styleAt(this.local - stagger(r.i, rows.length, { from: 'center', total: 0.4 }), RISE) }));
}
// tiles of a 6 by 4 grid ripple out from the top left corner
stagger(i, 24, { grid: [6, 4], each: 0.04 });
sweep(i, n, p, options)
sweep(i, n, p, options) returns item i's weight, 0 to 1, under a window that sweeps through the n items as p goes from 0 to 1.
The window starts before the first item and ends past the last, so nothing is under it at 0 and everything has passed it at 1, and an item's weight is the window's shape at the item's place in it.
This is the range selector of a text animator: the letter's change is scaled by its weight, where stagger() delays each letter's own timeline.
| Option | Default | Sets |
|---|---|---|
width | 0.3 | The window's width as a fraction of the items, above 0 and up to 4. A wider window has more items moving at once. |
shape | 'ramp' | 'ramp' rises from 0 as the window's leading edge reaches the item to 1 as its trailing edge passes, and holds. 'square' is 1 under the window and 0 outside it. 'triangle' rises and falls back over the window. 'round' is a half-sine bump. |
from | 'first' | The order, as stagger() has it in a row: 'first', 'last', 'center', 'edges', 'random', or an index. |
ease | 'linear' | Shapes the rise, and a triangle's fall, as anything ease() takes. |
seed | 1 | The order for 'random', a number or a string. |
p is any progress, ramp(this.local, 0.2, 1.4) or a track; below 0 it counts as 0 and above 1 as 1.
The weight serves as the time of a one-second set of tracks, so styleAt(w, RISE) with RISE's keys at 0 and 1 gives the letter's style from its weight, with the units and the transform folded as styleAt() folds them.
Spaces are rows and count, which pauses the sweep at each gap.
const { styleAt, sweep, split, ramp } = wildflower.kinetic;
const RISE = {
opacity: { at: [0, 1], to: [0, 1] },
translateY: { at: [0, 1], to: [40, 0] },
blur: { at: [0, 1], to: [6, 0] },
};
// the letters rise and fade in under a window two fifths of the word wide
letters() {
const rows = split('KINETIC');
const p = ramp(this.local, 0.2, 1.4, 'power1.inOut');
return rows.map((r) => ({ key: r.key, text: r.text,
style: r.space ? {} : styleAt(sweep(r.i, rows.length, p, { width: 0.4, ease: 'power2.out' }), RISE) }));
}
An i that is not a whole number from 0 to n - 1, an n that is not a whole number from 1 to 1000000, a p that is not a number, or a bad option throws (KN-100), and an unknown ease KN-121.
repeat(n, options)
repeat(n, options) returns rows { key, i, style } for a data-list of n copies of one element, each the transform of the one before it again: a repeater.
Copy k is moved by k times translateX and translateY, turned by k times rotate and scaled by scale to the power k, composed as one matrix(), so a turn with a move makes an arc and a scale with a move a spiral, about the element's transform-origin.
| Option | Default | Sets |
|---|---|---|
translateX, translateY | 0 | The move from one copy to the next, in px. |
rotate | 0 | The turn from one copy to the next, in degrees. |
scale | 1 | The scale from one copy to the next, above 0. |
opacity | None | [first, last], mixed across the copies, or the last copy's alone, from 1. |
offset | 0 | Shifts the series: copy k takes copy k + offset's transform, so a negative offset centers the copies on the original. A whole number. |
origin | None | A transform-origin for every copy, such as '50% 100%'. |
The numbers are plain numbers at a time, so a track drives any of them. The copies sit on one another to start, so the copy is absolutely positioned at the list element's origin and the list element is placed where the pivot goes.
const { repeat, interpolate } = wildflower.kinetic;
// twelve leaves fan out over 1.4 s, fading toward the last
copies() {
const turn = interpolate(this.local, [0.2, 1.6], [0, 15], { ease: 'power2.out' });
return repeat(12, { rotate: turn, opacity: [1, 0.35], origin: '50% 100%' });
}
An n that is not a whole number from 1 to 10000, or a bad option, throws (KN-100).
posterize(t, fps), loop(t, from, to) and hold(t, at, length)
These turn a time into another time, for styleAt(), interpolate() or any helper that takes one.
posterize(t, fps)stepsttofpsframes a second,Math.floor(t * fps + 1e-9) / fps, sostyleAt(posterize(this.local, 12), tracks)animates on twos at 24 fps.fpsis a finite number above 0.loop(t, from, to)returnstbeforefrom, and fromfromon cycles throughfromto just short ofto:from + ((t - from) mod (to - from)). A time a hair under a whole number of cycles counts as that cycle's start, and att = Infinityit returnsto, the end a track that wraps holds there.tois afterfrom, both finite.hold(t, at, length)returnstbeforeat, thenatforlengthseconds, thent - length, so the motion stops and carries on from where it stopped.lengthis a finite number, 0 or more.
A t that is not a number, or a bad second or third argument, throws (KN-100).
t and the lower one with the helper's result: posterize() steps it, loop() sends it back to from each time it reaches to, and hold() stops it at at for length seconds and then lets it carry on. A click on the stage pauses it.Timing tables
A timing table lists the moments inside one scene, such as its chapters, its cuts, or the steps of a diagram, as a plain array of objects.
Each row has at, the time it starts, and any fields of your own for what the scene shows then.
// beside the scene, outside its definition
const STEPS = [
{ at: 0, label: 'Sources', color: '#4280A2' },
{ at: 4.5, label: 'Generation', color: '#B1A8FF' },
{ at: 9, label: 'End uses', color: '#f2eee6', end: 13 }
];
atis in the scene's own seconds, the timethis.localgives, or in frames when the scene readsthis.clipFrame, as Cuts on whole frames does.- A row lasts until the next row starts, and an
endstops it sooner, which leaves a gap where nothing is current. - The rows can be in any order, since they are read in time order.
- Keep the table in a
constbeside the scene, or in a store or a query when the times come from data. A new array is read again, and an edit in place that keeps the length is not, so hand over a new array to change a time. moment(), below, reads the table. Times named on the audio, such as a marker for a drop, are read withtimeOf()instead, in composition seconds.
moment(t, rows)
moment(t, rows) returns the row of a timing table that is current at t, for the moments inside one scene, such as chapters, cuts, or the steps of a diagram.
The table stays a plain array of objects, each with at, the seconds it starts, and any fields of your own.
const CHAPTERS = [{ at: 0, name: 'Red' }, { at: 8.2, name: 'Yellow' }, { at: 16.4, name: 'Blue', end: 20 }];
chapter() { return wildflower.kinetic.moment(this.local, CHAPTERS); }
// at 10 s: { index: 1, row: CHAPTERS[1], start: 8.2, end: 16.4, since: 1.8, p: 0.22, next: CHAPTERS[2], empty: false, rows: [...] }
| Field | Value |
|---|---|
index | The current row's place in time order, or -1 when nothing is current. |
row | The current row itself, or null. |
start, end | The row's at, and its own end or the next row's at; Infinity for a last row with no end. |
since | The seconds since the row started. |
p | 0 to 1 through the row, and 0 while a last row with no end runs on. |
next | The row after the current one, or, when nothing is current, the row to come. |
empty | true before the first row and in a gap after a row's end. |
rows | Every row in time order as { key, row, state, p }, with state 'past', 'active' or 'future', for a list that highlights the current one. |
end below 7 and the second row stops before the third starts, which leaves a gap with no current row. A click on the stage pauses it.- A row runs from its
atuntil its ownend, or else until the next row starts. - Rows are read in time order, whatever order the array has, and of rows with equal times the last is current.
- The table is checked and sorted once per array and length, as
captions()groups its words, so a row pushed onto the array is seen and an edit in place that keeps the length is not. - A
tthat is not a number, rows that are not an array, a row with no finiteat, or anendbefore itsatthrows (KN-100).
focus(z, plane, options)
focus returns the blur in pixels of a thing at depth z with the focal plane at plane, in the same units, such as CSS translateZ pixels.
It is 0 on the plane, still small a little off it, and max well away from it, with the falloff max * (1 - 1 / (1 + (d / range)^2)) for the distance d between them.
const { focus } = wildflower.kinetic;
// a row at depth z, with plane a track that moves the focal plane over the clip
style: { filter: 'blur(' + focus(z, this.plane) + 'px)' }
focus(300, 100); // 6: d equals range, so half of max
focus(300, 100, { steps: 4 }); // 8: rounded to the nearest of 0, 4, 8 and 12
focus(300, 100, { depth: 200 }); // 0: inside the depth of field, as a real lens keeps a band sharp
| Option | Default | Meaning |
|---|---|---|
range | 200 | The distance over which the blur grows. Above 0. |
max | 12 | The blur in pixels far from the plane. 0 or more. |
steps | None | Quantise the result to this many levels, 2 or more, for a band of elements that share one filter. |
depth | 0 | The depth of field: everything within this distance either side of the plane is sharp, and the falloff starts at the band's edges. 0 or more. |
depth and the numbers near the plane turn sharp together, as a lens's depth of field keeps them.
The readout is the blur of the nearest, at 0.A z or plane that is not a finite number, or a bad option, throws (KN-100).
Each blurred element costs one GPU filter, so a filter per element suits about a dozen elements.
For hundreds, quantize with steps on a few band containers (Depth bands for hundreds of elements), or skip the filter and draw gradient discs (Bokeh particles with no filter).
See Which to use for the other ways to blur.
random(seed), noise(x, seed), noise2(x, y, seed) and noise3(x, y, z, seed)
noise(x, seed) is 1-D gradient noise in -1 to 1, 0 at whole numbers and the same for the same seed.
| Argument | Default | Sets |
|---|---|---|
x | None | The place on the curve: seconds for a drift, or an index plus time for one drift per entity. |
seed | None | The curve, a number or a string. |
noise(), noise2() and noise3() have no default seed, so a call without one throws (KN-100).
random(seed) returns a seeded generator, a mulberry32, and each call to it gives the next number in 0 to 1, the same sequence for the same seed on every machine.
Its seed is a number or a string, as for noise().
const { random, noise } = wildflower.kinetic;
const next = random('stars');
const x = next() * 1920; // the same x on every machine
const drift = noise(this.local, 'cloud'); // x in seconds: a drift
const sway = noise(i + this.local, 7); // index plus time: one drift per entity
noise2(x, y, seed) and noise3(x, y, z, seed) are the same noise in two and three dimensions, in -1 to 1, 0 at every point whose coordinates are all whole numbers, and smooth between.
Use noise2() for a field over a grid or a texture, and noise3() with time as the third coordinate for a field that drifts.
- They are the same for the same seed, built as
noise()is from the same seed hashing, the quintic fade6f⁵ − 15f⁴ + 10f³and a gradient at each corner of the lattice picked by a hash of the corner. - A coordinate that is not a finite number gives 0, as in
noise(), and a seed that is neither a number nor a string throws (KN-100).
The three stay within -1 to 1, and over a large sample, the more dimensions, the less of that range they use.
| Function | Reaches about | 95% of values within |
|---|---|---|
noise() | ±0.95 | ±0.62 |
noise2() | ±0.75 | ±0.35 |
noise3() | ±0.5 | ±0.25 |
Scale the result for the amount you want, such as 3 * noise3(...) for a value that mostly stays within ±0.75.
const { noise3 } = wildflower.kinetic;
// a 20 by 12 grid of dots whose sizes drift through a 3-D field over time,
// mostly between 0.55 and 1.45 times their own, and never below 0.05
dots() {
return Array.from({ length: 240 }, (_, i) => ({
key: i,
scale: 1 + 1.8 * noise3((i % 20) * 0.2, Math.floor(i / 20) * 0.2, this.local * 0.4, 'dots')
}));
}
wiggle(t, options)
wiggle(t, { frequency, amplitude, seed }) is After Effects' wiggle: a smooth value between -amplitude and amplitude that takes a new turn every 1 / frequency seconds and never passes the values either side.
| Option | Default | Sets |
|---|---|---|
frequency | 1 | Turns per second, above 0. |
amplitude | 1 | The range, -amplitude to amplitude, 0 or more. |
seed | 1 | The sequence, a number or a string: one per axis. |
A t that is not a finite number, or a bad option, throws (KN-100).
Stepped by posterize() at the same frequency, each step gets a value of its own, the jitter of pieces moved by hand between the exposures of stop motion.
const { wiggle, posterize } = wildflower.kinetic;
wiggle(t, { frequency: 2, amplitude: 10, seed: 1 }); // -10 to 10, a new value every half second
wiggle(posterize(t, 15), { frequency: 15, amplitude: 2, seed: 3 }); // stop motion