Skip to content

Instantly share code, notes, and snippets.

@nberlette
Created March 14, 2025 09:16
Show Gist options
  • Save nberlette/8b00655f7e2606b3f342329a1683077b to your computer and use it in GitHub Desktop.
Save nberlette/8b00655f7e2606b3f342329a1683077b to your computer and use it in GitHub Desktop.
TypeScript Easing / Tween API
export interface EasingMethods {
in(t: number): number;
out(t: number): number;
inOut(t: number): number;
}
interface EasingDef<K extends string = string> extends EasingMethods {
readonly name: K;
}
type BasicEasingNames =
"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out";
type FilterEasingNames<K extends keyof any> =
| K extends "prototype" ? never
: K extends keyof typeof Ease
? typeof Ease[K] extends Ease ? K : never
: never;
type EasingTypes = {
+readonly [K in keyof typeof Ease as FilterEasingNames<K>]: K;
};
type EasingNames = string & keyof EasingTypes;
export type EasingFunctionName = `${BasicEasingNames | EasingNames}` & {};
export class Ease<K extends string = string> {
static readonly linear = new Ease("linear", {
in: (x) => x,
out: (x) => x,
inOut: (x) => x,
});
static readonly ease = new Ease("ease", {
in: (t) => 1 - Math.cos(t * Math.PI / 2),
out: (t) => Math.sin(t * Math.PI / 2),
inOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
});
static readonly quad = new Ease("quad", {
in: (t) => t ** 2,
out: (t) => t * (2 - t),
inOut: (t) => t < 0.5 ? 2 * t ** 2: -1 + (4 - 2 * t) * t,
});
static readonly cubic = new Ease("cubic", {
in: (t) => t ** 3,
out: (t) => (--t) ** 3 + 1,
inOut: (t) =>
t < 0.5 ? 4 * t ** 3 : (t - 1) * ((2 * t - 2) ** 2) + 1,
});
static readonly quart = new Ease("quart", {
in: (t) => t ** 4,
out: (t) => 1 - (--t) ** 4,
inOut: (t) => t < 0.5 ? 8 * (t ** 4) : 1 - 8 * (--t) ** 4,
});
static readonly quint = new Ease("quint", {
in: (t) => t ** 5,
out: (t) => 1 + (--t) ** 5,
inOut: (t) =>
t < 0.5 ? 16 * t ** 5 : 1 + 16 * (--t) ** 5,
});
static readonly sine = new Ease("sine", {
in: (t) => 1 - Math.cos(t * Math.PI / 2),
out: (t) => Math.sin(t * Math.PI / 2),
inOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
});
static readonly expo = new Ease("expo", {
in: (t) => Math.pow(2, 10 * (t - 1)),
out: (t) => -Math.pow(2, -10 * t) + 1,
inOut: (t) =>
t < 0.5
? Math.pow(2, 20 * t - 10) / 2
: (2 - Math.pow(2, -20 * t + 10)) / 2,
});
static readonly circ = new Ease("circ", {
in: (t) => 1 - Math.sqrt(1 - t * t),
out: (t) => Math.sqrt(1 - (--t) * t),
inOut: (t) =>
t < 0.5
? (1 - Math.sqrt(1 - 4 * t * t)) / 2
: (Math.sqrt(1 - (t * 2 - 2) * (t * 2 - 2)) + 1) / 2,
});
static readonly back = new Ease("back", {
in: (t) => 2.70158 * (t ** 3) - 1.70158 * t * t,
out: (t) => 1 + 2.70158 * (--t) * t * t + 1.70158 * t * t,
inOut: (t) =>
t < 0.5
? 4 * (t ** 3) - 1.70158 * t * t
: 1 + 2.70158 * (--t) * t * t + 1.70158 * t * t,
});
static readonly elastic = new Ease("elastic", {
in: (t) => t === 0 ? 0 : t === 1 ? 1 : (
Math.sin((t * 10 - 10.75) * (2 * Math.PI) / 3) *
-Math.pow(2, 10 * t - 10)
),
out: (t) => t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin(
(t * 10 - 0.75) * (2 * Math.PI) / 3
) + 1,
inOut: (t) =>
t === 0 ? 0 : t === 1 ? 1 : t < 0.5
? -(Math.pow(2, 20 * t - 10) *
Math.sin((20 * t - 11.125) * (2 * Math.PI) / 4) / 2)
: Math.pow(2, -20 * t + 10) *
Math.sin((20 * t - 11.125) * (2 * Math.PI) / 4) / 2 + 1,
});
static readonly bounce = new Ease("bounce", {
in(t) {
return 1 - this.out(1 - t);
},
out(t) {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
},
inOut(t) {
return t < 0.5
? (1 - this.out(1 - 2 * t))
: (1 + this.out(2 * t - 1)) / 2;
},
});
static cubicBezier<
X1 extends number,
Y1 extends number,
X2 extends number,
Y2 extends number,
>(
x1: X1,
y1: Y1,
x2: X2,
y2: Y2,
): Ease<`cubic-bezier(${X1}, ${Y1}, ${X2}, ${Y2})`> {
return new Ease(`cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, {
in: (t) => {
const t2 = t * t, t3 = t2 * t;
const c = 3 * x1, b = 3 * (x2 - x1) - c, a = 1 - c - b;
return a * t3 + b * t2 + c * t;
},
out: (t) => {
const t2 = t * t, t3 = t2 * t;
const c = 3 * y1, b = 3 * (y2 - y1) - c, a = 1 - c - b;
return a * t3 + b * t2 + c * t;
},
inOut: (t) => (t < 0.5 ? 2 * t : 2 * (1 - t)) * t,
});
}
static get<
A extends string = string & keyof typeof Ease,
B extends "in" | "out" | "inOut" = "in" | "out" | "inOut",
>(name: `${A}-${B}`): A extends keyof typeof Ease ? Ease<A>[B] : undefined;
static get<A extends string = string & keyof typeof Ease>(
name: A,
): A extends keyof typeof Ease ? typeof Ease[A] : undefined;
static get(name: string): Ease | Ease[keyof Ease] | undefined {
const ease = Ease[name as keyof typeof Ease];
if (ease instanceof Ease) return ease;
const [key, method] = name.split("-");
return key in Ease && method in Object(Ease[key as EasingNames])
? Ease[key as EasingNames][method as "in" | "out" | "inOut"]
: undefined;
}
#name: K;
#in = (t: number) => t;
#out = (t: number) => t;
#inOut = (t: number) => t;
constructor(
definition: EasingDef<K> & ThisType<Ease<K>>,
);
constructor(
name: K,
methods: EasingMethods & ThisType<Ease<K>>,
);
constructor(
name: K | EasingDef<K>,
definition?: (EasingDef<K> | EasingMethods) & ThisType<Ease<K>>,
) {
if (typeof name === "object") ({ name, ...definition } = name);
this.#name = name;
this.#in = definition?.in ?? this.#in;
this.#out = definition?.out ?? this.#out;
this.#inOut = definition?.inOut ?? this.#inOut;
this.in = this.in.bind(this);
this.out = this.out.bind(this);
this.inOut = this.inOut.bind(this);
}
get name(): K {
return this.#name;
}
/**
* Calculate the easing value at time `t` for the `in` easing function.
*
* @param t Time value between 0 and 1
* @returns Eased value between 0 and 1
*/
in(t: number): number {
return this.#in(t);
}
/**
* Calculate the easing value at time `t` for the `out` easing function.
*
* @param t Time value between 0 and 1
* @returns Eased value between 0 and 1
*/
out(t: number): number {
return this.#out(t);
}
/**
* Calculate the easing value at time `t` for the `inOut` easing function.
*
* @param t Time value between 0 and 1
* @returns Eased value between 0 and 1
*/
inOut(t: number): number {
return this.#inOut(t);
}
/**
* Transition between two states using this easing function. This can be used
* to animate CSS properties or other values over time, using a given easing
* function and other configuration options. This function is a generator
* that yields the next value at each frame; if you'd rather have a Promise
* that resolves when the transition is complete, the `transition` method is
* more suitable to that end.
*
* The `animate` method wraps this function and provides a more convenient
* API for animating a target object's properties. Unlike this method, it
* mutates the target object's properties directly, and does not require
* manual iteration.
*
* @param from The initial state to transition from.
* @param to The final state to transition to.
* @param [options] Configuration options for the transition.
* @param [options.delay=0] Delay before starting the transition (ms)
* @param [options.duration=250] Duration of the transition (ms)
* @param [options.easing="ease-in-out"] Easing function to use.
* @param [options.direction="normal"] Direction of the transition.
* @param [options.fill="forwards"] Fill mode for the transition.
* @param [options.iterations=1] Number of iterations to run the transition
* @param [options.onFrame] Called once for every animation frame.
* @param [options.onComplete] Called when the transition is 100% complete.
* @param [options.onReverse] Called whenever a transition is reversed.
* @param [options.onStart] Called when a transition starts, after any delay.
*/
*tween<T extends Record<string, string | number>>(
from: T,
to: T,
{
delay = 0,
duration: dur = 250,
easing = "ease-in-out",
direction = "normal",
fill = "forwards",
iterations = 1,
}: TweenOptions<T> = {},
) {
const duration = isNaN(+dur) ? 250 : +dur;
const start = performance.now() + delay, end = start + duration;
const fps = 60, step = 1e3 / fps, frames = (duration / step) | 0;
let ease = this.inOut;
if (/(?:^|[- ])in$/.test(easing)) ease = this.in;
if (/(?:^|[- ])out$/.test(easing)) ease = this.out;
if (easing === "linear") ease = Ease.linear.inOut;
if (easing === "ease") ease = this.inOut;
if (easing === "ease-in") ease = this.in;
if (easing === "ease-out") ease = this.out;
if (easing === "ease-in-out") ease = this.inOut;
if (easing in Ease) ease = Ease[easing as EasingNames].inOut;
if (direction === "reverse") {
const tmp = from;
from = to;
to = tmp;
}
if (direction === "alternate-reverse") {
const tmp = from;
from = to;
to = tmp;
direction = "alternate";
}
let current = from;
if (fill === "backwards" || fill === "both") yield from;
if (fill === "none") current = from;
const animate = (t: number) => {
const progress = ease(t);
const value = {} as T;
for (const key in from) {
const a = from[key] as number;
const b = to[key] as number;
value[key] = a + (b - a) * progress;
}
return value;
};
if (iterations === Infinity) {
while (true) {
if (direction === "alternate") {
for (let t = 0; t <= 1; t += 1 / frames) {
const value = animate(t);
yield value;
if (t === 1) {
const tmp = from;
from = to;
to = tmp;
}
}
} else {
for (let t = 0; t <= 1; t += 1 / frames) {
const value = animate(t);
yield value;
}
}
}
} else {
for (let i = 0; i < iterations; i++) {
if (direction === "alternate") {
for (let t = 0; t <= 1; t += 1 / frames) {
const value = animate(t);
yield value;
if (t === 1) {
const tmp = from;
from = to;
to = tmp;
}
}
} else {
for (let t = 0; t <= 1; t += 1 / frames) {
const value = animate(t);
yield value;
}
}
}
}
if (fill === "forwards" || fill === "both") yield to;
if (fill === "both" || fill === "none") {
for (const key in from) {
current[key] = from[key];
}
}
}
toCSS(): string {
return `cubic-bezier(${this.#in(0)}, ${this.#out(1)}, ${
this.#inOut(0.5)
}, ${this.#inOut(0.5)})`;
}
toString(): string {
return this.name;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment