Created
March 14, 2025 09:16
-
-
Save nberlette/8b00655f7e2606b3f342329a1683077b to your computer and use it in GitHub Desktop.
TypeScript Easing / Tween API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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