Skip to content

2. Easing

Without easing, values move at constant speed and stop abruptly at each key. Easing changes the speed between keys, so motion feels natural. In Keyed, easing is set on a key with Key.at(t) instead of a plain time.

Influence

Inspired by After Effects, easing is an influence between 0 and 1 that slows the motion near a key.

easy = Keyed.ofFloat()
    .key(Key.at(0).setEasing(1 / 3), x0)
    .key(Key.at(1.5).setEasing(1 / 3), x1)
    .key(Key.at(3).setEasing(1 / 3), x0);
  • setEasing(1 / 3) is similar to After Effects' Easy Ease.
  • Higher influence, like setEasing(1), is slower near the keys and faster in between.
  • setEasingOut() only eases leaving a key: soft start, hard stop.
  • setEasingIn() only eases arriving at a key: hard start, soft stop.
Full sketch: Influence
let x0 = 40;
let x1 = 360;

let linear;
let easy;
let strong;
let easeOut;
let easeIn;

function setup() {
    createCanvas(400, 400);

    Keyed.init().setDuration(3);
    textFont('monospace');
    textSize(14);
    textAlign(LEFT, CENTER);

    // Without easing the speed is constant and stops are abrupt.
    linear = Keyed.ofFloat()
        .key(0, x0)
        .key(1.5, x1)
        .key(3, x0);

    // Influence in [0, 1] slows the motion near a key, like in After Effects.
    // 1/3 on both sides of a key is After Effects' Easy Ease.
    easy = Keyed.ofFloat()
        .key(Key.at(0).setEasing(1 / 3), x0)
        .key(Key.at(1.5).setEasing(1 / 3), x1)
        .key(Key.at(3).setEasing(1 / 3), x0);

    // More influence: slower near the keys, faster in between.
    strong = Keyed.ofFloat()
        .key(Key.at(0).setEasing(1), x0)
        .key(Key.at(1.5).setEasing(1), x1)
        .key(Key.at(3).setEasing(1), x0);

    // setEasingOut() only eases leaving a key: soft start, hard stop.
    easeOut = Keyed.ofFloat()
        .key(Key.at(0).setEasingOut(1), x0)
        .key(Key.at(1.5).setEasingOut(1), x1)
        .key(Key.at(3).setEasingOut(1), x0);

    // setEasingIn() only eases arriving at a key: hard start, soft stop.
    easeIn = Keyed.ofFloat()
        .key(Key.at(0).setEasingIn(1), x0)
        .key(Key.at(1.5).setEasingIn(1), x1)
        .key(Key.at(3).setEasingIn(1), x0);
}

function draw() {
    background(255);
    lane('linear', 70, linear);
    lane('setEasing(1 / 3f)', 135, easy);
    lane('setEasing(1)', 200, strong);
    lane('setEasingOut(1)', 265, easeOut);
    lane('setEasingIn(1)', 330, easeIn);
}

function lane(label, y, x) {
    noStroke();
    fill(150);
    text(label, x0, y - 20);

    stroke(220);
    line(x0, y, x1, y);

    noStroke();
    fill(0);
    circle(x.value(), y, 16);
}

Presets

setEasing() also accepts an Easing curve. Keyed has the usual presets:

  • QUAD, CUBIC, QUART and QUINT, each with _IN, _OUT and _IN_OUT, e.g. Easing.CUBIC_IN_OUT
  • Easing.SMOOTHSTEP and Easing.HOLD

An Easing shapes the whole segment from its key to the next one, so the last key doesn't need one:

Keyed.ofFloat()
    .key(Key.at(0).setEasing(Easing.CUBIC_IN_OUT), x0)
    .key(Key.at(1.5).setEasing(Easing.CUBIC_IN_OUT), x1)
    .key(3, x0);

An Easing is just a function from linear progress to eased progress, both from 0 to 1. Call it like e(d) to use it yourself, for example to draw the little graphs in the sketch. The presets also exist as plain functions, like Easing.quadIn(d).

Full sketch: Presets
let x0 = 100;
let x1 = 340;

// There are IN, OUT and IN_OUT presets for QUAD, CUBIC, QUART and QUINT,
// plus SMOOTHSTEP and HOLD.
let names = [
    'CUBIC_IN',
    'CUBIC_OUT',
    'CUBIC_IN_OUT',
    'QUAD_IN_OUT',
    'QUINT_IN_OUT',
    'SMOOTHSTEP',
];

let easings = [
    Easing.CUBIC_IN,
    Easing.CUBIC_OUT,
    Easing.CUBIC_IN_OUT,
    Easing.QUAD_IN_OUT,
    Easing.QUINT_IN_OUT,
    Easing.SMOOTHSTEP,
];

let lanes = [];

function setup() {
    createCanvas(400, 400);

    Keyed.init().setDuration(3);
    textFont('monospace');
    textSize(14);
    textAlign(LEFT, CENTER);

    for (const e of easings) {
        // An Easing shapes the whole segment from its key to the next one,
        // so the last key doesn't need one.
        lanes.push(
            Keyed.ofFloat()
                .key(Key.at(0).setEasing(e), x0)
                .key(Key.at(1.5).setEasing(e), x1)
                .key(3, x0),
        );
    }
}

function draw() {
    background(255);
    for (let i = 0; i < lanes.length; i++) {
        const y = 60 + i * 56;
        graph(easings[i], 30, y);
        lane(names[i], y, lanes[i]);
    }
}

// An Easing is a function from linear progress to eased progress, both 0..1.
function graph(e, x, y) {
    const s = 36;
    noFill();
    stroke(220);
    rect(x, y - s / 2, s, s);
    stroke(0);
    beginShape();
    for (let i = 0; i <= 40; i++) {
        const d = i / 40;
        vertex(x + d * s, y + s / 2 - e(d) * s);
    }
    endShape();
}

function lane(label, y, x) {
    noStroke();
    fill(150);
    text(label, x0, y - 20);

    stroke(220);
    line(x0, y, x1, y);

    noStroke();
    fill(0);
    circle(x.value(), y, 16);
}

Custom curves

When the presets aren't enough, build your own:

// Same as CSS cubic-bezier(); this one is CSS "ease".
Easing.cubicBezier(0.25, 0.1, 0.25, 1)
// y values above 1 overshoot the target...
Easing.cubicBezier(0.34, 1.56, 0.64, 1)
// ...and below 0 pull back before moving.
Easing.cubicBezier(0.36, 0, 0.66, -0.56)
// Starts at 0, ends at 1, pulled towards the middle value.
Easing.quadraticBezier(0, 1.4, 1)
// Waits until 0.3, moves smoothly, arrives at 0.7.
Easing.smoothstep(0.3, 0.7)
// Jumps in 4 equal steps, like CSS steps().
Easing.steps(4)

Easing is just a function type, so any function of d works too. This one bounces:

const bounce = (d) => 1 - abs(cos(d * 2.5 * PI)) * (1 - d);

There are also Easing.powerIn(p), powerOut(p) and powerInOut(p) for any power.

Full sketch: CustomBezier
let x0 = 100;
let x1 = 340;

let names = [
    'cubicBezier(0.25, 0.1, 0.25, 1)',
    'cubicBezier(0.34, 1.56, 0.64, 1)',
    'cubicBezier(0.36, 0, 0.66, -0.56)',
    'quadraticBezier(0, 1.4, 1)',
    'smoothstep(0.3, 0.7)',
    'steps(4)',
    'd => ... (bounce)',
];
let easings;

let lanes = [];

function setup() {
    createCanvas(400, 400);

    Keyed.init().setDuration(3);
    textFont('monospace');
    textSize(14);
    textAlign(LEFT, CENTER);

    easings = [
        // Same as CSS cubic-bezier(); this one is CSS "ease".
        Easing.cubicBezier(0.25, 0.1, 0.25, 1),
        // y values above 1 overshoot the target...
        Easing.cubicBezier(0.34, 1.56, 0.64, 1),
        // ...and below 0 pull back before moving.
        Easing.cubicBezier(0.36, 0, 0.66, -0.56),
        // Starts at 0, ends at 1, pulled towards the middle value.
        Easing.quadraticBezier(0, 1.4, 1),
        // Waits until 0.3, moves smoothly, arrives at 0.7.
        Easing.smoothstep(0.3, 0.7),
        // Jumps in 4 equal steps, like CSS steps().
        Easing.steps(4),
        // Easing is a function, so any function of d works.
        // This one hits the target, bounces back twice and settles.
        (d) => 1 - abs(cos(d * 2.5 * PI)) * (1 - d),
    ];

    for (const e of easings) {
        lanes.push(
            Keyed.ofFloat()
                .key(Key.at(0).setEasing(e), x0)
                .key(Key.at(1.5).setEasing(e), x1)
                .key(3, x0),
        );
    }
}

function draw() {
    background(255);
    for (let i = 0; i < lanes.length; i++) {
        const y = 45 + i * 52;
        graph(easings[i], 30, y);
        lane(names[i], y, lanes[i]);
    }
}

function graph(e, x, y) {
    const s = 36;
    noFill();
    stroke(220);
    rect(x, y - s / 2, s, s);
    stroke(0);
    beginShape();
    for (let i = 0; i <= 40; i++) {
        const d = i / 40;
        vertex(x + d * s, y + s / 2 - e(d) * s);
    }
    endShape();
}

function lane(label, y, x) {
    noStroke();
    fill(150);
    text(label, x0, y - 20);

    stroke(220);
    line(x0, y, x1, y);

    noStroke();
    fill(0);
    circle(x.value(), y, 16);
}

Hold

Sometimes values should jump instead of blend. There are two ways:

  • Even jumps: Easing.steps(n) on a single segment. The clock hand makes one full turn in 8 equal steps.
  • Uneven timing: hold() keeps a key's value until the next key. The traffic light is green for 2 seconds, yellow for 0.5 and red for 1.5.
hand = Keyed.ofFloat()
    .key(Key.at(0).setEasing(Easing.steps(8)), 0)
    .key(4, TWO_PI);

lamp = Keyed.ofInt()
    .key(Key.at(0).hold(), 2)
    .key(Key.at(2).hold(), 1)
    .key(2.5, 0);

Without hold(), the lamp index would be blended and rounded, switching halfway between keys.

Full sketch: Hold
let ticks = 8;
let hand;
let lamp;
let lampColors;
let offLampColor;

function setup() {
    createCanvas(400, 400);

    Keyed.init().setDuration(4);
    textFont('monospace');
    textSize(14);
    textAlign(CENTER, CENTER);

    // Even jumps: one full turn in 8 equal steps,
    // with a stepped easing on a single segment.
    hand = Keyed.ofFloat()
        .key(Key.at(0).setEasing(Easing.steps(ticks)), 0)
        .key(4, TWO_PI);

    // Uneven timing: green for 2 seconds, yellow for 0.5, red for 1.5.
    // hold() keeps a key's value until the next key. Without it the lamp
    // index would be blended and rounded, switching halfway between keys.
    lamp = Keyed.ofInt()
        .key(Key.at(0).hold(), 2)
        .key(Key.at(2).hold(), 1)
        .key(2.5, 0);

    // Red, yellow and green, from top to bottom.
    lampColors = [
        color(230, 60, 60),
        color(240, 190, 40),
        color(60, 180, 90),
    ];
    offLampColor = color(235);
}

function draw() {
    background(255);
    clock('steps(8)', 110, 200, hand.value());
    trafficLight('hold()', 290, 200, lamp.value());
}

function clock(label, x, y, angle) {
    const r = 70;

    noFill();
    stroke(220);
    circle(x, y, r * 2);
    strokeWeight(5);
    for (let i = 0; i < ticks; i++) {
        const a = i * TWO_PI / ticks - HALF_PI;
        point(x + cos(a) * (r - 10), y + sin(a) * (r - 10));
    }

    stroke(0);
    strokeWeight(3);
    const a = angle - HALF_PI;
    line(x, y, x + cos(a) * (r - 20), y + sin(a) * (r - 20));
    strokeWeight(1);

    noStroke();
    fill(150);
    text(label, x, y + 120);
}

function trafficLight(label, x, y, lit) {
    noFill();
    stroke(220);
    rect(x - 35, y - 95, 70, 190, 12);

    noStroke();
    for (let i = 0; i < 3; i++) {
        fill(i === lit ? lampColors[i] : offLampColor);
        circle(x, y - 60 + i * 60, 44);
    }

    fill(150);
    text(label, x, y + 120);
}

Next: Timelines.