@thednp/tween supports only numbers by default for maximum performance and simplicity, where the original @tweenjs/tweenjs goes to support unlimited nested objects.
To interpolate other types (quaternions / tupples / arrays, SVG paths, transforms, objects, etc.), you explicitly register custom interpolation and validation functions for any property using tween.use() / timeline.use().
Why this is important:
- we don't guess what kind of values we have, how to interpolate them, we explicitly specify
- this is the only way to provide predictability: if values aren't numbers, specify how to validate and interpolate and for which property
- this is the best way to make sure your app / page never breaks because animation update loops
- this is the best way to avoid leaks and keep performance in check
As a general rule, we can only use extensions for root properties of the target object, and never as nested object properties. While in theory this might work, we cannot guarantee a safe and consistent outcome.
The definitions are fairly open in the sense that you can extend even further:
type BaseTweenProps = Record<string, number>;
type CubicValues = [number, number, number, number, number, number];
type QuadValues = [number, number, number, number];
type LineValues = [number, number];
type Vec3 = [number, number?, number?];
type ArrayVal = number[] | [string, ...number[]][];
// You have plenty of room to extend to your needs
type TweenProps = Record<
string, // property name
number // number value
| ArrayVal // a generic array value
| BaseTweenProps // single level nested object
| [ // MorphPathArray | TransformArray
string,
...(CubicValues | LineValues | QuadValues | Vec3),
][]
>;
// how Tween / Timeline generics look like
class Tween<T extends TweenProps> {}NOTE: as you can see we're pretty biased towards tupples (arrays), since they're superior in terms of performance.
Let's assume you want to support a specific structure:
- First you specify the type:
type YourType = { x: number, y: number } // a plain one level nested object- Next you define and configure your tween object:
// initialize Tween, at this stage YourType is not supported
const tween = new Tween({ myProp: { x: 0, y: 0 } });
// the extension will validate the initial values once .use() is called
tween.use("myProp", {
interpolate: (start: YourType, end: YourType, progress: number) => { /* lerp logic */ },
validate: (propName: string, value: unknown, ref?: YourType) => [boolean, string?]
});
// other to() / from() calls will validate YourType and check compatibility with
// validated initial values
tween.to({ myProp: { x: 150, y: 150 } });- At any point during or after configuration, you can check your tween validity:
// now all values are valid, we can check
tween.isValidState // => true (initial values are validated by all validators)
tween.isValid // => true (no errors remaining)- If you like what you see, all vallues are validated as compatible for interpolation, start whenever needed:
// we can start the tween
tween.start();NOTE: If initial validation fails, the validation function should usually provide feedback on how to solve the issues.
The package has a series built-in extensions that follow the same pattern:
- interpolate function — computes the value at
progressvalue (the eased value in 0-1 range) - validate function — runs at config time (
constructor(initialValues),.to(endValues),.from(startValues)) to catch errors early
The interpolator returns the exact same type of value:
const interpolate = (start: YourType, end: YourType, progress: number): YourTypeThe validator works like this:
const validate = (propName: string, value: unknown, reference?: YourType) => {
// STEP 1 - make sure `value` is valid as YourType
// - numbers must be `number` in all YourType structure
// - strings / object keys must be correct according to YourType
// - if no `reference` provided return a tuple [result: boolean, reason?: "Why it failed"]
// STEP 2 - make sure `value` is compatible with your `reference`
// - check array length / object keys length
// - check specific values within structure to make sure `value` - `reference` interpolatation compatibility
// - return a tuple [result: boolean, reason?: "Why values didn't match"]
}Important: Call
.use()before.to()/.from()to validate initial values first. Validation happens only at configuration time — no cost during animation. Warnings (if any remain) appear only when callingtween.start()/timeline.play().
The built-in extensions allow you to tween structured values. Let's say you may have a special need to validate numbers in a more specific or strict way like having a minimum of 0 and a maximum of 255 for RGB colors for instance.
For properties like quaternions [x,y,z,t], RGB [r,g,b], vectors [x,y,z], etc. For your reference, check the extend/array.ts extension source code.
NOTE: not to be confused with Array Interpolation from the original Tween.js.
The arrayConfig extension consists of:
- interpolate: linear interpolation (lerp) per array element
- validate: checks all values are numbers and array length matches reference
import { Tween, arrayConfig } from "@thednp/tween";
// initialize and configure Tween
const tween = new Tween({ rgb: [255, 0, 0] }) // initial values aren't validated yet
.use("rgb", arrayConfig) // now initial values are valid
.to({ rgb: [255, 128, 0] }); // to()/from() are compared to the initial values reference
// consume the rgb value
tween.onUpdate(obj => {
targetElement.style.setProperty("background-color", `rgb(${obj.rgb})`)
})This extension is for morphing SVG paths and comes with an additional utility to convert PathArray values to string.
The pathArrayConfig extension consists of:
- interpolate: lerps segment coordinates (if not "Z" segment)
- validate: checks valid commands, parameter counts, structure match for interpolation compatibility
The included pathToString utility converts the PathArray value to a valid HTML description (d) attribute string.
First let's look into what is a PathArray: it's an array of segments / tuples with the following specification:
M/m- the MoveTo segment:["M", number, number],C/c- the CubicBezierTo segment"["C", number, number, number, number, number, number],S/s- the CubicBezierTo shorthand segment:["S", number, number, number, number],Q/q- the QuadraticBezierTo segment:["Q", number, number, number, number],T/t- the QuadraticBezierTo shorthand segment:["T", number, number],L/l- the LineTo segment:["L", number, number]Z/z- the ClosePath segment:["Z"].
Then MorphPathArray is comprised of only M, C, L, Z path segments, especially CubicBezierTo and LineTo segments are ideal for processing and interpolation.
export type LineValues = [number, number];
export type CubeValues = [number, number, number, number, number, number];
export type MorphPathSegment =
| ["M" | "L", ...LineValues]
| ["C", ...CubeValues]
| ["Z"];
export type MorphPathArray = MorphPathSegment[];import { Tween, pathArrayConfig, pathToString } from "@thednp/tween";
const initialValue = {
path: [
["M", 0, 0],
["L",50,50],
["L", 0,50],
["Z"]
]
}
const target = document.getElementById("my-target");
const tween = new Tween(initialValue);
tween.use("path", pathArrayConfig);
tween.to({
path: [
["M",50, 0],
["L",50,50],
["L", 0,50],
["Z"]
]
});
// specify how to use the interpolated PathArray value
tween.onUpdate(obj =>
// manipulate the DOM directly
target.setAttribute("d", pathToString(obj.path))
)NOTE: Both paths must have identical structure (same number/order of segments and commands). Complex morphs may need preprocessing (e.g. Flubber).
Notes
- The example provides ready-made
PathArrayobjects, they usually require prior preparation manually or using some script to equalize segments; - Continuous
pathupdates between multiple shapes requires that all path values are compatible, which means they all have same amount of segments and all segments are of the same type (ideal are[[M, x, y], ...[L, x, y]],OR[[M, x, y], ...[C, cx1, cy1, cx2, cy2, x, y]],); - Our svg-path-commander provides all the tools necessary to process path strings, optimize and even equalize segments.
When morphing paths with different structures (different number of segments or commands), you need to equalize them first. The svg-path-commander library provides equalizePaths and equalizeSegments utilities for this purpose and are included with the exports of @thednp/tween package.
Using equalizePaths (handles multi-subpath shapes):
import { Tween, pathArrayConfig, equalizePaths, pathToString } from "@thednp/tween";
// Two shapes with different segment counts
const pathA = "M0 0L100 0L50 100Z"; // triangle (3 segments)
const pathB = "M0 0L100 0L100 100L0 100Z"; // rectangle (4 segments)
// Equalize both paths to have matching structure
const [eqA, eqB] = equalizePaths(pathA, pathB, { close: true });
const target = document.getElementById("my-target");
const tween = new Tween({ path: eqA });
tween.use("path", pathArrayConfig);
tween.to({ path: eqB });
tween.onUpdate(obj =>
target.setAttribute("d", pathToString(obj.path))
);
tween.start();Using equalizeSegments (single-subpath shapes, simpler):
import { Tween, pathArrayConfig, pathToString, equalizeSegments } from "@thednp/tween";
const star = "M50 0L61 35L98 35L68 57L79 91L50 70L21 91L32 57L2 35L39 35Z";
const circle = "M50 0C77.6 0,100 22.4,100 50C100 77.6,77.6 100,50 100C22.4 100,0 77.6,0 50C0 22.4,22.4 0,50 0Z";
const [eqStar, eqCircle] = equalizeSegments(star, circle);
const tween = new Tween({ path: eqStar })
.use("path", pathArrayConfig)
.to({ path: eqCircle })
.duration(1.5)
.onUpdate(obj => target.setAttribute("d", pathToString(obj.path)))
.start();Pre-processing paths at build/preparation time (recommended for performance):
import { equalizePaths } from "svg-path-commander";
// Run this once during setup, not at animation time
const [start, end] = equalizePaths("M10 10h80v80h-80z", "M50 0l61 35-23 70-76 0-23-70z", { close: true });
// Animation code uses pre-equalized paths
const tween = new Tween({ path: start })
.use("path", pathArrayConfig)
.to({ path: end });This extension is for CSS3/WebGL transforms with complex and multiple steps 2D/3D transform functions.
import { Tween, transformConfig, transformToString } from "@thednp/tween";
const target = document.getElementById("my-target")
const tween = new Tween({
transform: [
["translate", 0, 0],
["rotate", 0],
["scale", 1, 1],
["skewX", 0],
["translate", 0, 0] // SVG origin trick
]
})
tween.use("transform", transformConfig);
tween.to({
transform: [
["translate", 300, 100],
["rotate", 45],
["scale", 1.5, 1.5],
["skewX", 30],
["translate", -150, -50] // SVG origin trick
]
});
tween.onUpdate(state => {
Object.assign(
target.style,
transform: transformToString(state.transform) // helper included
)
});transformConfig consists of:
interpolate— lerps each component in ordervalidate: checks transform function names, lengths/parameters counts, structure match with reference
Interpolator returns same TransformArray structure — convert to string with transformToString.
TIPS
- Supports multiple same commands (e.g. translate → rotate → translate-back), you can use only 2D transforms to apply to SVG elements
transformproperty, but you must usetransformToString(state.transform, true)(when second parameter is true, the returned string is amatrix(...)/matrix3d(...)). - The included
transformToStringwith or without the second parameter may differ in visual presentation when rotations are used, that is because the resulting transform string usesrotateX(angle),rotateY(angle)androtateZ(angle)(they are separate transform steps) while the resulting matrix string applies all rotations in a singlematrix.rotate(x,y,z)operation; to solve this you can make use of the includedeulerToAxisAngleutility to convert the["rotate", x,y,z]transform step to["rotateAxisAngle", x,y,z,angle]; with this conversion, yourtransformToString(array)andtransformToString(array, true)will look visually identical and have the exact same computed style.
NOTE - the
transformToString(array, true)(returning amatrix()ormatrix3d()string) is a little more expensive than a regular transform string as it uses the nativeDOMMatrixinstance that multiplies with a new matrix for every transform step. The performance caracteristics are different in many ways (regular transform string involves dynamic string concatenation, DOMMatrix does all computation and string concatenation internally), however the impact is negligible for a small number of tweens.
For simple nested properties like transforms (Eg. { translate: { x: 0, y: 0 } }). We don't recommend using objects but if it's a must for your project, you can do that too. Example:
import { Tween, objectConfig } from "@thednp/tween";
const tween = new Tween({
transform: {
rotateX: 0,
rotateY: 0,
translateX: 0,
translateY: 0,
});
tween.use("transform", objectConfig);
tween.to({
transform: {
rotateX: 15,
rotateY: 25,
translateX: 100,
translateY: 100,
}
});objectConfig consists of:
- interpolate: lerps inner numbers only
- validate: checks single-level nesting, inner values are numbers, object keys match
NOTE: Deeper nesting objects are blocked.
If initial validation fails, the validation function should provide feedback on how to solve the issues, for instance:
-
On intialization, if extension is not registered via
use()[Tween] failed validation: - Property "translate" of type "object" is not supported yet. -
If initial values are valid calling
to()/from()with invalid values you should get this:[Tween] failed validation: - Property "x" from "translate" is not a number. - Property "z" from "translate" does not exist in the reference object. - Property "y" from "translate" is null/undefined.
@thednp/tween does not support deep nested objects.
Why not? Deep nested merging adds measurable overhead in the hot update path (every frame) and makes updating your application state more dificult. We prioritize raw speed and simplicity. Both the Tween and Timeline constructors will prevent you from using them.
Instead, why not flatten your objects:
// Helper to flatten nested objects (one-time cost)
// Ideal at a preparation phase
type FlatObject = Record<string, number>
type DeepObject = Record<
string,
number | FlatObject
>;
function flatten<T extends DeepObject>(obj: T, prefix = ""): FlatObject {
if (!(obj !== undefined && typeof obj === "object" &&
Object.getPrototypeOf(obj) === Object.prototype)) {
console.warn("'obj' is not a plain object");
return {};
}
const result = {};
const keys = Object.keys(obj) as (keyof T)[];
let i = 0;
const len = keys.length;
while (i < len) {
const key = String(keys[i++]);
const value = obj[key];
const fullKey = (prefix ? `${prefix}.${key}` : String(key));
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flatten(value, fullKey));
} else {
result[fullKey] = value;
}
}
return result as FlatObject;
}
// Usage
const nested = { scale: { x: 0, y: 0 }, rotate: 0 };
const to = { scale: { x: 100, y: 150 }, rotate: 45 };
// this is your initialValues/state
const initialValues = flatten(nested); // → { "scale.x": 1, "scale.y": 1, "rotate": 0 }
// this is your end values
const endValues = flatten(to); // → { "scale.x": 100, "scale.y": 150, "rotate": 45 }
const tween = new Tween(initialValues)
.to(endValues)
.duration(1.5)
.start();
// Or in hooks:
const [state, tween] = useTween(initialValues);
tween.to(endValues)Want colors, quaternions, matrices, audio params, shaders? Just write your own interpolator + validator and register with .use().
How about an optimized extension for RGB(a) tuples that:
- reuses the
arrayConfigextension - validates values properly (3 values in 0-255 range, alpha value in 0-1 range)
// app/src/animations/rgb.ts
import {
isNumber
interpolateArray,
validateArray
type InterpolatorFunction,
type ValidationResultEntry,
} from "@thednp/tween";
/**
* RGB(A) Validation (extends array validation)
*/
export const validateRgb = <T extends number[]>(
propName: string,
target: unknown,
ref?: T,
): ValidationResultEntry => {
// 1. Reuse basic array checks
const [isValid, reason] = validateArray(propName, target, ref);
if (!isValid) return [false, reason];
const arr = target as number[];
// 2. Must have 3 or 4 values
if (arr.length !== 3 && arr.length !== 4) {
return [
false,
`Property "${propName}" must be an RGB [r,g,b] or RGBA [r,g,b,a] array (3 or 4 numbers).`,
];
}
// 3. RGB values: 0–255 (integers or floats allowed, but usually ints)
for (let i = 0; i < 3; i++) {
const v = arr[i];
if (!isNumber(v) || v < 0 || v > 255) {
return [
false,
`RGB component #${i + 1} in "${propName}" must be a number between 0 and 255.`,
];
}
}
// 4. Optional alpha: 0–1
if (arr.length === 4) {
const a = arr[3];
if (!isNumber(a) || a < 0 || a > 1) {
return [
false,
`Alpha channel in "${propName}" must be a number between 0 and 1.`,
];
}
}
return [true];
};
/**
* Ready-to-use config for tween.use("rgb", rgbConfig)
*/
export const rgbConfig = {
interpolate: interpolateArray,
validate: validateRgb,
};Use your extension right away:
// app/src/App.js
import { Tween, Easing } from "@thednp/tween";
import { rgbConfig } from "./animations/rgb.ts";
const tween = new Tween({ rgb: [0,156,0] })
.use("rgb", rgbConfig)
.easing(Easing.Quadratic.Out)
.to({ rgb: [255,0,0]})
.onUpdate(state =>
Object.assign(target.style, { color: `rgb(${state.rgb})` })
);
tween.start();😊 Happy tweening!