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.

OptionDefaultSets
stiffness100The spring's pull, above 0.
damping10The friction, 0 or more: below 2 * Math.sqrt(stiffness * mass), 20 for the defaults, the spring overshoots.
mass1The weight it moves, above 0.
from0The value at rest before the spring is released.
to1The value it settles at.
overshootClampingfalseHolds the value between from and to.
durationNoneSeconds until the spring stays within 2% of the distance from to (KN-123's measure), above 0.
bounce0With duration, 0 to below 1: the damping ratio is 1 - bounce, so 0 never overshoots.
The ball goes from the left mark, 0, to the right mark, 1, by the call shown, and is released again every 3 seconds. Click the stage to pause.

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.

  • t is a number, and from and to are finite with to after from, 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 interpolate takes, mix as in interpolate, per channel or in oklch with options.space, into an rgb() or rgba() 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 p that is not a finite number, a space that 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.
OptionDefaultSets
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(...)'
The box slides from -300 to 0 by the call shown, and tints from one color to the other by the same ramp, m, over a 3-second loop. The bar along the stage's foot marks the ramp's window and the time. Click the stage to pause.

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() or rgba() string a color track writes, and the two keyframes come back exactly.
  • Another space throws (KN-120 on a track or interpolate(), KN-100 on mix()).
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)'
The top strip mixes the pair per channel and the bottom one in oklch, each at 24 steps, with a marker at p. The swatch is the call's own result.

stagger(i, n, options)

stagger(i, n, options) returns item i's delay in seconds, for styleAt(this.local - stagger(i, n, options), tracks).

OptionDefaultSets
each0.05Seconds per step of distance from the origin. 0 or more.
totalNoneSeconds 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.
gridNone[columns, rows], with a cell for each item.
axisNoneWith grid, 'x' or 'y': the distance along that axis only.
ease'linear'How the delays are spread, as anything ease() takes.
seed1The order for 'random', a number or a string.
Bar i of 12 rises after the delay the call gives it, and the ease bunches the starts toward one end or the other; the row rises again every 4 seconds, and a click on the stage pauses.

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 d of the way from the nearest distance to the farthest starts at ease(d) times the span, which is total, or each times the farthest distance past the nearest.
  • With 'center' and an even n, 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.

FromDistance 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.

OptionDefaultSets
width0.3The 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.
seed1The 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) }));
}
Bar i of 12 is as tall as its weight, the faint slot behind it a weight of 1, and p runs from 0 to 1 along the stage's foot every 3.6 seconds. Click the stage to pause.

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.

OptionDefaultSets
translateX, translateY0The move from one copy to the next, in px.
rotate0The turn from one copy to the next, in degrees.
scale1The scale from one copy to the next, above 0.
opacityNone[first, last], mixed across the copies, or the last copy's alone, from 1.
offset0Shifts the series: copy k takes copy k + offset's transform, so a negative offset centers the copies on the original. A whole number.
originNoneA 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%' });
}
The leaves are the copies the call returns, placed on the dot, and the outlined one is the last.

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) steps t to fps frames a second, Math.floor(t * fps + 1e-9) / fps, so styleAt(posterize(this.local, 12), tracks) animates on twos at 24 fps. fps is a finite number above 0.
  • loop(t, from, to) returns t before from, and from from on cycles through from to just short of to: from + ((t - from) mod (to - from)). A time a hair under a whole number of cycles counts as that cycle's start, and at t = Infinity it returns to, the end a track that wraps holds there. to is after from, both finite.
  • hold(t, at, length) returns t before at, then at for length seconds, then t - length, so the motion stops and carries on from where it stopped. length is a finite number, 0 or more.

A t that is not a number, or a bad second or third argument, throws (KN-100).

The upper dot moves with 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 }
];
  • at is in the scene's own seconds, the time this.local gives, or in frames when the scene reads this.clipFrame, as Cuts on whole frames does.
  • A row lasts until the next row starts, and an end stops 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 const beside 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 with timeOf() 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: [...] }
FieldValue
indexThe current row's place in time order, or -1 when nothing is current.
rowThe current row itself, or null.
start, endThe row's at, and its own end or the next row's at; Infinity for a last row with no end.
sinceThe seconds since the row started.
p0 to 1 through the row, and 0 while a last row with no end runs on.
nextThe row after the current one, or, when nothing is current, the row to come.
emptytrue before the first row and in a gap after a row's end.
rowsEvery row in time order as { key, row, state, p }, with state 'past', 'active' or 'future', for a list that highlights the current one.
The chips are the four rows, the current one in the accent and the past ones dimmed, and the bar is its progress. Move 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 at until its own end, 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 t that is not a number, rows that are not an array, a row with no finite at, or an end before its at throws (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
OptionDefaultMeaning
range200The distance over which the blur grows. Above 0.
max12The blur in pixels far from the plane. 0 or more.
stepsNoneQuantise the result to this many levels, 2 or more, for a band of elements that share one filter.
depth0The 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.
Each number stands at the depth it shows, smaller the farther back, and is blurred by the call at that z. Raise 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.

ArgumentDefaultSets
xNoneThe place on the curve: seconds for a drift, or an index plus time for one drift per entity.
seedNoneThe curve, a number or a string.
The dot's height is the call shown, with 1 near the top of the stage and -1 near the bottom. Click the stage to pause.

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 fade 6f⁵ − 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.

FunctionReaches about95% 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')
    }));
}
Each dot of a 20 by 8 grid is scaled by the call at its cell, x across and y down, with time as the third coordinate. Click the stage to pause.

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.

OptionDefaultSets
frequency1Turns per second, above 0.
amplitude1The range, -amplitude to amplitude, 0 or more.
seed1The sequence, a number or a string: one per axis.
The card's x is the call shown, and its y the same call with another seed. Click the stage to pause.

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