Working code for the article "Tweening and Easing from Scratch: Penner Equations, a Tween Runner and a Live Curve Visualizer". No dependencies; the library side is pure TypeScript math.
Contents:
- 31 easing functions (
src/easing.ts) —linear+ 10 families × (In,Out,InOut), derived from 10 bases viaout(f)andinOut(f). - Tween runner (
src/tween.ts) —Tween,Sequence,Parallel. Never looks at the wall clock:update(dt)takes time from the outside and returns the UNCONSUMED leftover time. - Live visualizer (
src/demo.ts+src/plot.ts) — a clickable grid of the 31 curves, a ball running along the selected curve, alinearreference strip, a duration slider and asequence(parallel(...), ...)choreography showcase. - Bench (
src/bench-cli.ts) — the contract table (f(0),f(1), min/max), call throughput and the measurement of "swallowed remainder" lag.
npm installnpm run dev # Vite dev server → http://localhost:5173/
npm test # vitest (headless, no DOM)
npm run build # tsc --noEmit + vite build
npm run bench # contract + throughput + lag tables
npx tsc --noEmit # type check onlyThe demo does not open over
file://— ES module imports do not work, you get a blank screen.npm run devis required.
- Grid (960×520): 31 curves, dashed 0/1 reference lines in each cell. Click → select.
- Stage (540×560): the selected curve is drawn large, with a red ball running on it.
The strip below repeats the same motion with
linear— the difference shows side by side. - Picking
elasticOutmakes the ball visibly overshoot the top dashed line (max 1.3731), pickingbackIntakes it below the bottom line (min −0.1000). - Showcase (400×560): a panel sliding with
cubicOut+ a card arriving with thesequence(parallel(backOut, sineOut), quadOut, elasticOut)choreography. It repeats itself every 2.6 seconds, and there is a "replay the choreography" button too.
npm test108 tests, 3 files:
test/easing.test.ts(89) —f(0)=0/f(1)=1for all 31 functions, no NaN,InOutsymmetry; theout/inOutderivation identities (includingout(out(f)) === f); overshoot tests (backIn< −0.05,backOut> 1.05,elasticOut> 1.3,elasticIn< −0.3,bounceOut∈ [0,1]); family character (quad→quint stiffness order,expoIn(0.5) === quintIn(0.5), bounce's three trough points).test/tween.test.ts(17) — the expecteddtsequence, clamp + returning the leftover time,duration = 0,reset,sequenceorder/handoff,parallelcompleting according to the latest finisher, nested choreography.test/demo.test.ts(2) — headless smoke test forsrc/demo.ts: 120 frames are run with a minimal Canvas2D fake, verifying that the ball crosses the 1 reference line.
There is no vi.useFakeTimers() in the tests: we already produce time ourselves.
npm run bench prints three tables. Summary:
31 of 31 functions satisfy the contract.
Intentionally exceeding 0-1 range (6): backIn, backOut, backInOut, elasticIn, elasticOut, elasticInOut
function ms calls/sec ns/call
--------------------------------------------------------
linear 4.8 1,044,659,180 0.96
quadIn 36.7 136,313,447 7.34
quintInOut 71.5 69,948,820 14.30
sineIn 74.5 67,155,371 14.89
expoIn 240.7 20,774,152 48.14
circIn 39.8 125,672,609 7.96
backOut 75.2 66,503,290 15.04
elasticOut 325.9 15,342,149 65.18
bounceOut 104.1 48,033,604 20.82
Swallowed remainder — 10-tween chain, 30 FPS (dt = 1/30 s)
chain ideal s correct frames swallow frames lag s
----------------------------------------------------------------------------
10 x 0.20 s 2.00 61 70 0.300
10 x 0.25 s 2.50 76 80 0.133
10 x 0.30 s 3.00 90 90 0.000
10 x 0.35 s 3.50 105 110 0.167
Timing numbers vary by machine; the table structure and the frame counts (deterministic) do not.
src/
easing.ts # 31 easings: 10 bases + out()/inOut()/family()
tween.ts # lerp, tweenValue, Tweenable, Tween, Sequence, Parallel
plot.ts # plotCurve (PAD = 0.45, dashed 0/1 lines), plotCell
demo.ts # grid + stage + showcase, rAF loop (the ONLY place the wall clock is read)
benchmark.ts # contract(), throughput(), swallowLag()
bench-cli.ts # CLI that prints the tables
test/
easing.test.ts
tween.test.ts
demo.test.ts
index.html
- The easing contract consists of nothing but
f(0)=0andf(1)=1. Monotonicity is not part of the contract;backandelasticdeliberately leave the range. The test verifies this, it does not forbid it. out(f) = t => 1 - f(1 - t)is its own inverse; that is whybounce, whose natural base isOut, also fits into thefamily()system without trouble.- If
update(dt)does not return the leftover time, long chains lag. Measured: at 30 FPS a chain of 10 × 0.20 s takes 70 frames instead of 61 (0.300 s). - If you do not clamp
elapsedtoduration,t > 1happens; values likeexpoIn(1.4) = 16throw the object off-screen for a frame.
MIT