Learn Creative Coding (#16) - Easing and Lerp: Smooth Motion

Welcome to Phase 3. We spent the last six episodes building tools -- particles, noise, trig, Bezier curves, and a whole galaxy. But everything we've animated so far moved at constant speed or with raw sine oscillation. That's fine for a spinning spiral, but watch any real object in the world: a ball rolling to a stop, a cat jumping off a shelf, your finger flicking through a phone screen. Nothing moves at constant speed. Things accelerate. They decelerate. They overshoot and settle. Our brains are tuned to detect unnatural motion (we evolved to track predators, after all), so getting motion curves right makes a massive difference in how your animations feel.
This episode is about two things: lerp (linear interpolation) and easing functions. Lerp is the foundation -- it blends between two values. Easing functions shape that blend into something that feels alive. Together they control how values transition from one state to another: position, size, color, opacity, rotation, anything. Once you internalize this, you'll use it in literally every animated sketch you write. It's that fundamental :-)
What is lerp?
Linear interpolation. The simplest way to compute a value between two endpoints:
function lerp(a, b, t) {
return a + (b - a) * t;
}
When t=0, you get a. When t=1, you get b. At t=0.5, you're exactly halfway. The parameter t (always 0 to 1) is your progress along the journey from a to b.
lerp(0, 100, 0.0) // 0
lerp(0, 100, 0.25) // 25
lerp(0, 100, 0.5) // 50
lerp(0, 100, 1.0) // 100
Lerp works with anything numerical: positions, colors, sizes, angles, opacity. "I'm at A, I want to get to B, how far along am I?" That's lerp. One function, runs half of all animation in creative coding.
p5.js has a built-in lerp() but you should understand what it does, because once you do, you'll see opportunities to use it everywhere. And in vanilla Canvas (where we've been working since episode 9), you'll need your own anyway.
The lerp-toward-target pattern
This is the most common pattern in creative coding. I use it constantly at work, in personal sketches, everywhere:
let x = 200, y = 200;
function draw() {
background(20);
// each frame, move 5% closer to the mouse
x = lerp(x, mouseX, 0.05);
y = lerp(y, mouseY, 0.05);
fill(100, 200, 255);
noStroke();
ellipse(x, y, 30, 30);
}
That 0.05 is the magic number. Each frame, the circle moves 5% of the remaining distance toward the mouse. It starts fast (when the gap is large) and slows down as it approaches. This is already easing -- exponential decay, technically. And it's one line of math.
See how natural it feels? The circle chases your cursor with organic, springy motion. Move the mouse fast and it trails behind; stop moving and it glides smoothly to rest. Compare that to x = mouseX; y = mouseY which would snap instantly. Same destination, completely different experience.
The 0.05 controls the feel. Smaller values = slower, smoother, more fluid movement. 0.02 feels floaty and dreamy. 0.15 feels responsive and snappy. Try different values -- there's no "correct" one, it depends on the mood you're going for.
Why linear motion looks wrong
Watch a circle move from point A to point B at constant speed:
let progress = 0;
function draw() {
background(20);
progress += 0.01;
if (progress > 1) progress = 0;
let x = lerp(100, 500, progress);
fill(255);
ellipse(x, 200, 20, 20);
}
It starts instantly, moves at exactly the same speed the entire time, and stops dead. Robotic. Mechanical. Nothing in nature moves like this -- even a sliding hockey puck has friction slowing it down.
Linear motion is the uncanny valley of movement. Your brain flags it as "wrong" even if you can't articulate why. We spent hundreds of thousands of years tracking moving objects (prey, predators, thrown rocks), and our visual processing is deeply tuned to distinguish natural from unnatural motion.
Easing functions fix this.
Easing functions: shaping time
An easing function takes a linear t (0 to 1) and curves it. The input is uniform time progression. The output is shaped motion. Same distance traveled, same duration -- completely different feel.
Ease-in (slow start, fast end)
function easeInQuad(t) {
return t * t;
}
function easeInCubic(t) {
return t * t * t;
}
At t=0.5 (halfway through time), easeInQuad returns 0.25 -- only a quarter of the way there. The motion loads up slowly then accelerates hard at the end. Like a ball rolling downhill, or a car pulling away from a traffic light.
The math is dead simple. Squaring t makes small values even smaller (0.1 becomes 0.01) while values near 1 stay close to 1 (0.9 becomes 0.81). That's your slow start. Cubic (t*t*t) makes it more dramatic -- higher powers = more pronounced easing. You can go to any power you want.
Ease-out (fast start, slow end)
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
The inverse. Jumps forward quickly, then settles gently into the destination. Like throwing a ball that decelerates due to friction. Or a menu sliding into view -- it shoots out fast, then smoothly decelerates to its final position.
The formula inverts the ease-in: flip t, apply the power, flip the result. If ease-in is "slow to start," ease-out is "slow to stop." Same math, mirror image.
Ease-in-out (slow start AND end)
function easeInOutQuad(t) {
return t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
The most natural-feeling one for UI and animation. Gentle departure, acceleration in the middle, gentle landing. It splits at t=0.5 -- first half is ease-in, second half is ease-out. The piecewise formula looks intimidating but it's really just two halves glued together.
As a rule of thumb: ease-out for things appearing or arriving (they zoom in and settle). Ease-in for things disappearing or leaving (they accelerate away). Ease-in-out for things moving between two states (smooth departure, smooth arrival). These aren't hard rules -- they're defaults that feel natural because they match how physical objects behave under gravity and friction.
Seeing them side by side
const easings = [
{ name: 'linear', fn: t => t },
{ name: 'easeIn', fn: t => t * t * t },
{ name: 'easeOut', fn: t => 1 - Math.pow(1 - t, 3) },
{ name: 'easeInOut', fn: t => t < 0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2 },
];
let progress = 0;
function setup() {
createCanvas(600, 300);
textSize(14);
}
function draw() {
background(20);
progress = (progress + 0.005) % 1;
for (let i = 0; i < easings.length; i++) {
let y = 50 + i * 60;
let eased = easings[i].fn(progress);
let x = lerp(80, 550, eased);
fill(150);
noStroke();
text(easings[i].name, 5, y + 5);
fill(100, 200, 255);
ellipse(x, y, 15, 15);
}
}
Four circles moving from left to right. Same timing, wildly different feel. Linear looks robotic. EaseIn feels heavy and reluctant. EaseOut feels snappy and confident. EaseInOut feels polished and professional. Run this sketch and you'll immediately see the difference -- it's one of those "aha" moments that changes how you think about animation.
Fun easings: elastic and bounce
These go beyond simple polynomial curves. They overshoot and oscillate, adding physical personality to motion.
function easeOutElastic(t) {
if (t === 0 || t === 1) return t;
return Math.pow(2, -10 * t) *
Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1;
}
Elastic overshoots the target and wobbles back, like a spring. The Math.pow(2, -10 * t) creates exponential decay (the wobbles get smaller over time) and the Math.sin(...) creates the oscillation. It's an exponentially decaying sine wave -- the same concept we used for twinkle animation in the galaxy project (episode 15), but here it's controlling position instead of opacity.
function easeOutBounce(t) {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
t -= 1.5 / 2.75;
return 7.5625 * t * t + 0.75;
} else if (t < 2.5 / 2.75) {
t -= 2.25 / 2.75;
return 7.5625 * t * t + 0.9375;
} else {
t -= 2.625 / 2.75;
return 7.5625 * t * t + 0.984375;
}
}
Bounce simulates a ball hitting the floor. Four parabolic segments, each shorter than the last. It looks ugly in code but the result is delightful -- things arrive with a playful bounce that makes your sketch feel alive. These constants (7.5625, 2.75) come from Robert Penner's classic easing equations from the Flash era. They've been copied into every animation library since. The math was solved once in 2001 and nobody's needed to solve it again :-)
Both elastic and bounce are fantastic for playful UI -- buttons that pop, elements that arrive with personality, notifications that demand attention.
Animating anything with easing
Easing isn't just for position. Anything that changes over time can be eased -- size, color, rotation, opacity, blur, stroke weight. Here's a shape that animates four properties at once, each with a different easing function:
let startTime = null;
let duration = 2000; // 2 seconds
function setup() {
createCanvas(400, 400);
startTime = millis();
}
function draw() {
background(20);
let elapsed = millis() - startTime;
let t = constrain(elapsed / duration, 0, 1);
// each property gets a different easing
let size = lerp(0, 200, easeOutElastic(t));
let r = lerp(255, 50, easeOutCubic(t));
let g = lerp(50, 200, easeOutCubic(t));
let b = lerp(50, 255, easeOutCubic(t));
let alpha = lerp(0, 255, easeOutQuad(t));
let rotation = lerp(0, PI, easeInOutCubic(t));
push();
translate(200, 200);
rotate(rotation);
fill(r, g, b, alpha);
noStroke();
rectMode(CENTER);
rect(0, 0, size, size, size * 0.1);
pop();
}
function mousePressed() {
startTime = millis(); // click to restart
}
Size pops with elastic overshoot. Color shifts smoothly. Opacity fades in quickly. Rotation does a polished half-turn. Four eased properties, one animation. The elastic on size makes the whole thing feel energetic -- the square overshoots its target size, bounces back, settles. Without easing, this would be a boring linear grow-and-rotate. With easing, it has character.
The pattern here is reusable: calculate a normalized t from elapsed time and duration, apply easing to get an eased t, then lerp your properties using that eased value. Same structure works for any timed animation.
The lerp-each-frame pattern in practice
Remember the lerp-toward-target pattern from the beginning? Here's the full version with multiple properties all smoothly chasing their targets:
let targetX = 300, targetY = 200;
let targetSize = 50;
let targetR = 255, targetG = 100, targetB = 50;
let curX = 0, curY = 0;
let curSize = 0;
let curR = 0, curG = 0, curB = 0;
function setup() {
createCanvas(600, 400);
}
function draw() {
background(20);
let speed = 0.08;
curX = lerp(curX, targetX, speed);
curY = lerp(curY, targetY, speed);
curSize = lerp(curSize, targetSize, speed);
curR = lerp(curR, targetR, speed);
curG = lerp(curG, targetG, speed);
curB = lerp(curB, targetB, speed);
fill(curR, curG, curB);
noStroke();
ellipse(curX, curY, curSize, curSize);
}
function mousePressed() {
targetX = mouseX;
targetY = mouseY;
targetSize = random(20, 100);
targetR = random(255);
targetG = random(255);
targetB = random(255);
}
Click anywhere. The circle smoothly chases the new position, size, and color. No animation timeline, no keyframes, no state tracking. Just lerp. Change the targets anytime from mouse clicks, keyboard input, data feeds, whatever -- and everything animates smoothly and automatically.
This is why creative coders love lerp. It's simple enough to use everywhere but powerful enough to make things look polished. Every property follows its target independently. Position chases position, color chases color, size chases size. You could add 20 more lerped properties and the pattern stays the same.
One practical thing to watch out for: the lerp-each-frame pattern is frame-rate dependent. At 60fps, speed = 0.05 feels different than at 30fps. If you need frame-rate independence, multiply the speed by deltaTime / 16.67 (where 16.67ms is one frame at 60fps). For creative coding sketches it usually doesn't matter, but if you're building something interactive for other people, it's worth knowing.
Chaining animations
What if you want one easing to play, then a different one? Multiple animations in sequence, each starting when the previous ends:
function chainedEase(t, stages) {
let stageLength = 1 / stages.length;
for (let i = 0; i < stages.length; i++) {
if (t <= (i + 1) * stageLength) {
let localT = (t - i * stageLength) / stageLength;
return stages[i](localT);
}
}
return 1;
}
// first ease in smoothly, then bounce to a stop
let eased = chainedEase(progress, [easeInCubic, easeOutBounce]);
The function divides the overall progress into equal segments and applies a different easing to each. The localT variable remaps each segment's t back to 0-1. This is the foundation for complex animation sequences -- a series of eased transitions playing in order. Combine this with the different easing functions and you can create motion that tells a story: hesitate, launch, overshoot, settle.
Drawing easing curves
Here's a sketch that visualizes what easing functions actually look like as curves. The x-axis is input time, the y-axis is output progress. Once you see the shapes, the behavior makes intuitive sense:
function setup() {
createCanvas(600, 600);
background(20);
let fns = [
{ name: 'easeInQuad', fn: t => t*t },
{ name: 'easeOutQuad', fn: t => 1-(1-t)*(1-t) },
{ name: 'easeInOutCubic', fn: t => t<0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2 },
{ name: 'easeOutElastic', fn: easeOutElastic },
{ name: 'easeOutBounce', fn: easeOutBounce },
{ name: 'linear', fn: t => t },
];
let cols = 3, rows = 2;
let cellW = width / cols, cellH = height / rows;
for (let i = 0; i < fns.length; i++) {
let col = i % cols;
let row = Math.floor(i / cols);
let ox = col * cellW + 20;
let oy = row * cellH + 40;
let w = cellW - 40;
let h = cellH - 60;
// label
fill(150);
noStroke();
textSize(12);
text(fns[i].name, ox, oy - 10);
// axes
stroke(60);
strokeWeight(1);
line(ox, oy, ox, oy + h);
line(ox, oy + h, ox + w, oy + h);
// the curve
stroke(100, 200, 255);
strokeWeight(2);
noFill();
beginShape();
for (let s = 0; s <= 100; s++) {
let t = s / 100;
let eased = fns[i].fn(t);
let px = ox + t * w;
let py = oy + h - eased * h;
vertex(px, py);
}
endShape();
}
}
Six easing curves in a grid. Linear is a straight diagonal line. EaseIn curves away from the start (slow departure). EaseOut curves into the end (slow arrival). Elastic goes above 1.0 and oscillates back down. Bounce dips and bounces at the bottom. Seeing these shapes is the fastest way to build intuition about which easing to pick for which situation.
Bookmarking a cheat sheet of easing curves is one of the best things you can do for your animation work. Search for "easings.net" -- it has interactive visualizations of 30+ easing functions with the math right there. I still reference it regularly.
Trig-based easing
You can also use sin and cos for easing. Remember episode 13 where we used sin as an oscillator? Same functions, different application:
// smooth ease-out using sine
function easeOutSine(t) {
return Math.sin(t * Math.PI / 2);
}
// smooth ease-in using cosine
function easeInSine(t) {
return 1 - Math.cos(t * Math.PI / 2);
}
// smooth ease-in-out using cosine
function easeInOutSine(t) {
return -(Math.cos(Math.PI * t) - 1) / 2;
}
Sine-based easings have a particularly organic feel because the acceleration is sinusoidal rather than polynomial. It's a gentler curve than quadratic or cubic -- more gradual start and stop. Think of it like the difference between a car braking hard (cubic) versus coasting to a stop (sine). Both work, different character.
Practical example: eased hover effects
Easing turns boring state changes into polished interactions. Here's a grid of circles that respond to the mouse with eased animations:
let circles = [];
function setup() {
createCanvas(500, 500);
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
circles.push({
x: 35 + col * 62,
y: 35 + row * 62,
curSize: 20,
curBrightness: 80,
baseSize: 20,
});
}
}
}
function draw() {
background(20);
noStroke();
for (let c of circles) {
let d = dist(mouseX, mouseY, c.x, c.y);
let targetSize = d < 80 ? 45 - d * 0.2 : c.baseSize;
let targetBright = d < 80 ? 255 - d * 1.5 : 80;
// lerp toward targets -- this IS the easing
c.curSize = lerp(c.curSize, targetSize, 0.12);
c.curBrightness = lerp(c.curBrightness, targetBright, 0.1);
fill(c.curBrightness, c.curBrightness * 0.8, 255);
ellipse(c.x, c.y, c.curSize, c.curSize);
}
}
Move your mouse over the grid. Circles near the cursor grow and brighten, then smoothly shrink back when the mouse moves away. That smooth return is the easing. Without the lerp, they'd snap instantly between states -- jarring and cheap-looking. With lerp, the whole thing feels fluid and alive. The cursor acts like a gravity well of light, similar to the mouse gravity we built in the galaxy project (episode 15), but applied to visual properties instead of position.
This is the kind of interaction that separates a "student project" from "professional work." The difference is literally two lines of lerp. That's it.
Where easing meets physics
Easing functions model idealized motion curves. Real physics is messier -- springs overshoot, friction depends on velocity, gravity is constant acceleration. But easing functions are a good-enough approximation for most creative coding. When you need something that genuinely simulates physical behavior -- springs that bounce, objects that collide, flocking behavior -- you'll want actual physics simulation. We'll get into that in a couple episodes :-)
The elastic easing function is actually a decent spring approximation for simple cases. But if you want a spring with controllable stiffness, dampening, and mass -- that needs a real spring equation with velocity and acceleration, not a parametric t-to-t mapping. There's a whole world of motion design between "eased lerp" and "full physics engine," and we'll explore that space as we move through Phase 3.
For now, know that easing handles 90% of animation needs. The remaining 10% is where proper physics comes in.
Making easing interactive: a mini playground
Here's a sketch where you can see all the easings at once, with a draggable progress slider. Good for building intuition about how each function feels:
let fns = [
{ name: 'linear', fn: t => t },
{ name: 'quad', fn: t => t*t },
{ name: 'cubic', fn: t => t*t*t },
{ name: 'quart', fn: t => t*t*t*t },
{ name: 'outCubic', fn: t => 1-Math.pow(1-t,3) },
{ name: 'inOutCubic', fn: t => t<0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2 },
{ name: 'elastic', fn: easeOutElastic },
{ name: 'bounce', fn: easeOutBounce },
];
let sliderT = 0;
let dragging = false;
function setup() {
createCanvas(600, 500);
textSize(12);
}
function draw() {
background(20);
// slider bar
let sx = 50, sy = 470, sw = 500;
stroke(60);
strokeWeight(2);
line(sx, sy, sx + sw, sy);
fill(100, 200, 255);
noStroke();
ellipse(sx + sliderT * sw, sy, 16, 16);
// animated circles
for (let i = 0; i < fns.length; i++) {
let y = 30 + i * 54;
let eased = fns[i].fn(sliderT);
let x = lerp(100, 550, eased);
fill(150);
noStroke();
text(fns[i].name, 10, y + 5);
fill(100, 200, 255);
ellipse(x, y, 12, 12);
// trail showing the curve shape
stroke(100, 200, 255, 40);
strokeWeight(1);
for (let s = 0; s < 50; s++) {
let st = s / 50;
let se = fns[i].fn(st);
point(lerp(100, 550, se), y);
}
}
}
function mousePressed() {
if (mouseY > 450) dragging = true;
}
function mouseReleased() {
dragging = false;
}
function mouseDragged() {
if (dragging) {
sliderT = constrain((mouseX - 50) / 500, 0, 1);
}
}
Drag the slider at the bottom and watch eight different easings respond simultaneously. This is the best way to feel the difference between them -- quad vs cubic vs quart, how elastic overshoots, how bounce settles. Once you've played with this for a few minutes, you'll intuitively know which easing to reach for.
Easing in practice: what to ease and when
Not everything needs easing. Static elements don't move. Elements driven by noise or trig already have natural variation built in (remember our noise-wobbled orbits from episode 15?). Easing shines for transitions -- things moving from one state to another in response to an event. Mouse click, timer tick, state change.
Here's my mental checklist:
- Something appearing? Ease out. Fast entrance, smooth arrival.
- Something disappearing? Ease in. Smooth departure, fast exit.
- Something moving between two positions? Ease in-out. Smooth both ends.
- Something that should feel playful? Elastic or bounce.
- Something that should feel subtle? Sine easing.
- Interactive response (cursor tracking, hover)? Lerp-each-frame with 0.05 to 0.15 speed.
The difference between amateur and professional animation often comes down to easing. Linear motion looks like a PowerPoint slide transition from 2005. Properly eased motion looks like a polished app or a Pixar movie. Same distance, same duration, completley different feel.
Once you start seeing easing, you can't unsee it. Every app on your phone, every website transition, every motion graphics piece -- they're all using easing functions. Now you know how they work.
't Komt erop neer...
lerp(a, b, t)blends between two values -- the single most useful function in animation- The lerp-toward-target pattern (
value = lerp(value, target, speed)each frame) gives everything smooth, organic-feeling motion - Linear motion looks robotic because nothing in nature moves at constant speed
- Ease-in = slow start, ease-out = slow end, ease-in-out = smooth both ends
- The math is simple: quadratic is
t*t, cubic ist*t*t, higher powers = more dramatic - Elastic overshoots and wobbles back (decaying sine wave). Bounce simulates a ball dropping
- Easing applies to everything: position, color, size, rotation, opacity -- anything numerical
- Sine-based easings have a gentler, more organic feel than polynomial ones
- The lerp-each-frame pattern is endlessly versatile for interactive work
- Pick your easing based on the motion's personality: professional, playful, subtle, dramatic
Phase 3 is rolling. We've got smooth motion now, but our animations are still one thing moving at a time. What happens when you have multiple animation states -- intro, idle, active, exit -- and need to manage transitions between them? That's where things get interesting, and we'll dig into that soon.
Sallukes! Thanks for reading.
X