Emulating CSS Timing Functions with JavaScript | CSS-Tricks

原文出处 Emulating CSS Timing Functions with JavaScript | CSS-Tricks

# Emulating CSS Timing Functions with JavaScript

CSS animations and transitions are great! However, while recently toying with an idea, I got really frustrated with the fact that gradients are only animatable in Edge (and IE 10+). Yes, we can do all sorts of tricks with background-position, background-size, background-blend-mode or even opacity and transform on a pseudo-element/ child, but sometimes these are just not enough. Not to mention that we run into similar problems when wanting to animate SVG attributes without a CSS correspondent.

Using a lot of examples, this article is going to explain how to smoothly go from one state to another in a similar fashion to that of common CSS timing functions using just a little bit of JavaScript, without having to rely on a library, so without including a lot of complicated and unnecessary code that may become a big burden in the future.

This is not how the CSS timing functions work. This is an approach that I find simpler and more intuitive than working with Bézier curves. I'm going to show how to experiment with different timing functions using JavaScript and dissect use cases. It is not a tutorial on how to do beautiful animation.

A few examples using a linear timing function

Let's start with a left to right linear-gradient() with a sharp transition where we want to animate the first stop. Here's a way to express that using CSS custom properties:

`background: linear-gradient(90deg, #ff9800 var(--stop, 0%), #3c3c3c 0);`

On click, we want the value of this stop to go from 0% to 100% (or the other way around, depending on the state it's already in) over the course of NF frames. If an animation is already running at the time of the click, we stop it, change its direction, then restart it.

We also need a few variables such as the request ID (this gets returned by requestAnimationFrame), the index of the current frame (an integer in the [0, NF] interval, starting at 0) and the direction our transition is going in (which is 1 when going towards 100% and -1 when going towards 0%).

While nothing is changing, the request ID is null. We also set the current frame index to 0 initially and the direction to -1, as if we've just arrived to 0% from 100%.

const NF = 80; // number of frames transition happens over

let rID = null, f = 0, dir = -1;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null;  
};

function update() {};

addEventListener('click', e => {
  if(rID) stopAni(); // if an animation is already running, stop it
  dir *= -1; // change animation direction
  update();
}, false);

Now all that's left is to populate the update() function. Within it, we update the current frame index f. Then we compute a progress variable k as the ratio between this current frame index f and the total number of frames NF. Given that f goes from 0 to NF (included), this means that our progress k goes from 0 to 1. Multiply this with 100% and we get the desired stop.

After this, we check whether we've reached one of the end states. If we have, we stop the animation and exit the update() function.

function update() {
  f += dir; // update current frame index

  let k = f/NF; // compute progress

  document.body.style.setProperty('--stop', `${+(k*100).toFixed(2)}%`);

  if(!(f%NF)) {
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

The result can be seen in the Pen below (note that we go back on a second click):

See the Pen by thebabydino (@thebabydino) on CodePen.

The way the pseudo-element is made to contrast with the background below is explained in an older article.

The above demo may look like something we could easily achieve with an element and translating a pseudo-element that can fully cover it, but things get a lot more interesting if we give the background-size a value that's smaller than 100% along the x axis, let's say 5em:

See the Pen by thebabydino (@thebabydino) on CodePen.

This gives us a sort of a "vertical blinds" effect that cannot be replicated in a clean manner with just CSS if we don't want to use more than one element.

Another option would be not to alternate the direction and always sweep from left to right, except only odd sweeps would be orange. This requires tweaking the CSS a bit:

--c0: #ff9800;
--c1: #3c3c3c;
background: linear-gradient(90deg, 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

In the JavaScript, we ditch the direction variable and add a type one (typ) that switches between 0 and 1 at the end of every transition. That's when we also update all custom properties:

const S = document.body.style;

let typ = 0;

function update() {
  let k = ++f/NF;

  S.setProperty('--stop', `${+(k*100).toFixed(2)}%`);

  if(!(f%NF)) {
    f = 0;
    S.setProperty('--gc1', `var(--c${typ})`);
    typ = 1 - typ;
    S.setProperty('--gc0', `var(--c${typ})`);
    S.setProperty('--stop', `0%`);
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

This gives us the desired result (click at least twice to see how the effect differs from that in the first demo):

See the Pen by thebabydino (@thebabydino) on CodePen.

We could also change the gradient angle instead of the stop. In this case, the background rule becomes:

background: linear-gradient(var(--angle, 0deg), 
    #ff9800 50%, #3c3c3c 0);

In the JavaScript code, we tweak the update() function:

function update() {
  f += dir;

  let k = f/NF;

  document.body.style.setProperty(
    '--angle', 
    `${+(k*180).toFixed(2)}deg`
  );

  if(!(f%NF)) {
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

We now have a gradient angle transition in between the two states (0deg and 180deg):

See the Pen by thebabydino (@thebabydino) on CodePen.

In this case, we might also want to keep going clockwise to get back to the 0deg state instead of changing the direction. So we just ditch the dir variable altogether, discard any clicks happening during the transition, and always increment the frame index f, resetting it to 0 when we've completed a full rotation around the circle:

function update() {
  let k = ++f/NF;

  document.body.style.setProperty(
    '--angle', 
    `${+(k*180).toFixed(2)}deg`
  );

  if(!(f%NF)) {
    f = f%(2*NF);
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

addEventListener('click', e => {
  if(!rID) update()
}, false);

The following Pen illustrates the result - our rotation is now always clockwise:

See the Pen by thebabydino (@thebabydino) on CodePen.

Something else we could do is use a radial-gradient() and animate the radial stop:

background: radial-gradient(circle, 
    #ff9800 var(--stop, 0%), #3c3c3c 0);

The JavaScript code is identical to that of the first demo and the result can be seen below:

See the Pen by thebabydino (@thebabydino) on CodePen.

We may also not want to go back when clicking again, but instead make another blob grow and cover the entire viewport. In this case, we add a few more custom properties to the CSS:

--c0: #ff9800;
--c1: #3c3c3c;
background: radial-gradient(circle, 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

The JavaScript is the same as in the case of the third linear-gradient() demo. This gives us the result we want:

See the Pen by thebabydino (@thebabydino) on CodePen.

A fun tweak to this would be to make our circle start growing from the point we clicked. To do so, we introduce two more custom properties, --x and --y:

background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

When clicking, we set these to the coordinates of the point where the click happened:

addEventListener('click', e => {
  if(!rID) {
    S.setProperty('--x', `${e.clientX}px`);
    S.setProperty('--y', `${e.clientY}px`);
    update();
  }
}, false);

This gives us the following result where we have a disc growing from the point where we clicked:

See the Pen by thebabydino (@thebabydino) on CodePen.

Another option would be using a conic-gradient() and animating the angular stop:

`background: conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0%)`

Note that in the case of conic-gradient(), we must use a unit for the zero value (whether that unit is % or an angular one like deg doesn't matter), otherwise our code won't work - writing conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0) means nothing gets displayed.

The JavaScript is the same as for animating the stop in the linear or radial case, but bear in mind that this currently only works in Chrome with Experimental Web Platform Features enabled in chrome://flags.

Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome Canary (62.0.3184.0).

The Experimental Web Platform Features flag enabled in Chrome Canary (63.0.3210.0).

Just for the purpose of displaying conic gradients in the browser, there's a polyfill by Lea Verou and this works cross-browser but doesn't allow using CSS custom properties.

The recording below illustrates how our code works:

Recording of how our first conic gradient demo works in Chrome with the flag enabled.

Recording of how our first conic-gradient() demo works in Chrome with the flag enabled (live demo).

This is another situation where we might not want to go back on a second click. This means we need to alter the CSS a bit, in the same way we did for the last radial-gradient() demo:

--c0: #ff9800;
--c1: #3c3c3c;
background: conic-gradient( 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0%)

The JavaScript code is exactly the same as in the corresponding linear-gradient() or radial-gradient() case and the result can be seen below:

Recording of how our second conic gradient demo works in Chrome with the flag enabled.

Recording of how our second conic-gradient() demo works in Chrome with the flag enabled (live demo).

Before we move on to other timing functions, there's one more thing to cover: the case when we don't go from 0% to 100%, but in between any two values. We take the example of our first linear-gradient, but with a different default for --stop, let's say 85% and we also set a --stop-fin value - this is going to be the final value for --stop:

--stop-ini: 85%;
--stop-fin: 26%;
background: linear-gradient(90deg, #ff9800 var(--stop, var(--stop-ini)), #3c3c3c 0)

In the JavaScript, we read these two values - the initial (default) and the final one - and we compute a range as the difference between them:

const S = getComputedStyle(document.body), 
      INI = +S.getPropertyValue('--stop-ini').replace('%', ''), 
      FIN = +S.getPropertyValue('--stop-fin').replace('%', ''), 
      RANGE = FIN - INI;

Finally, in the update() function, we take into account the initial value and the range when setting the current value for --stop:

document.body.style.setProperty(
  '--stop', 
  `${+(INI + k*RANGE).toFixed(2)}%`
);

With these changes we now have a transition in between 85% and 26% (and the other way on even clicks):

See the Pen by thebabydino (@thebabydino) on CodePen.

If we want to mix units for the stop value, things get hairier as we need to compute more things (box dimensions when mixing % and px, font sizes if we throw em or rem in the mix, viewport dimensions if we want to use viewport units, the length of the 0% to 100% segment on the gradient line for gradients that are not horizontal or vertical), but the basic idea remains the same.

Emulating ease-in/ ease-out

An ease-in kind of function means the change in value happens slow at first and then accelerates. ease-out is exactly the opposite - the change happens fast in the beginning, but then slows down towards the end.

Illustration showing the graphs of the ease-in and ease-out timing functions, both defined on the [0,1] interval, taking values within the [0,1] interval. The ease-in function has a slow increase at first, the change in value accelerating as we get closer to 1\. The ease-out function has a fast increase at first, the change in value slowing down as we get closer to 1.The ease-in (left) and ease-out (right) timing functions (live).

The slope of the curves above gives us the rate of change. The steeper it is, the faster the change in value happens.

We can emulate these functions by tweaking the linear method described in the first section. Since k takes values in the [0, 1] interval, raising it to any positive power also gives us a number within the same interval. The interactive demo below shows the graph of a function f(k) = pow(k, p) (k raised to an exponent p) shown in purple and that of a function g(k) = 1 - pow(1 - k, p) shown in red on the [0, 1] interval versus the identity function id(k) = k (which corresponds to a linear timing function).

See the Pen by thebabydino (@thebabydino) on CodePen.

When the exponent p is equal to 1, the graphs of the f and g functions are identical to that of the identity function.

When exponent p is greater than 1, the graph of the f function is below the identity line - the rate of change increases as k increases. This is like an ease-in type of function. The graph of the g function is above the identity line - the rate of change decreases as k increases. This is like an ease-out type of function.

It seems an exponent p of about 2 gives us an f that's pretty similar to ease-in, while g is pretty similar to ease-out. With a bit more tweaking, it looks like the best approximation is for a p value of about 1.675:

See the Pen by thebabydino (@thebabydino) on CodePen.

In this interactive demo, we want the graphs of the f and g functions to be as close as possible to the dashed lines, which represent the ease-in timing function (below the identity line) and the ease-out timing function (above the identity line).

Emulating ease-in-out

The CSS ease-in-out timing function looks like in the illustration below:

Illustration showing the graph of the ease-in-out timing function, defined on the [0,1] interval, taking values within the [0,1] interval. This function has a slow rate of change in value at first, then accelerates and finally slows down again such that it's symmetric with respect to the (½,½) point.The ease-in-out timing function (live).

So how can we get something like this?

Well, that's what harmonic functions are for! More exactly, the ease-in-out out shape is reminiscent the shape of the sin() function on the [-90°,90°] interval.

The sin(k) function on the [-90°,90°] interval. At -90°, this function is at its minimum, which is -1\. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 90°. At 90°, the sin(k) function is at its maximum, which is 1\. Its graph is symmetrical with respect to the (0°,0) point.The sin(k) function on the [-90°,90°] interval (live).

However, we don't want a function whose input is in the [-90°,90°] interval and output is in the [-1,1] interval, so let's fix this!

This means we need to squish the hashed rectangle ([-90°,90°]x[-1,1]) in the illustration above into the unit one ([0,1]x[0,1]).

First, let's take the domain [-90°,90°]. If we change our function to be sin(k·180°) (or sin(k·π) in radians), then our domain becomes [-.5,.5] (we can check that -.5·180° = 90° and .5·180° = 90°):

The sin(k·π) function on the [-½,½] interval. At -½, the sin(k·π) function is at its minimum, which is -1\. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to ½. At ½, the sin(k·π) function is at its maximum, which is 1\. Its graph is symmetrical with respect to the (0,0) point.The sin(k·π) function on the [-.5,.5] interval (live).

We can shift this domain to the right by .5 and get the desired [0,1] interval if we change our function to be sin((k - .5)·π) (we can check that 0 - .5 = -.5 and 1 - .5 = .5):

The sin((k - ½)·π) function on the [0,1] interval. At 0, the sin(((k - ½)·π) function is at its minimum, which is -1\. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1\. At 1, the sin((k - ½)·π) function is at its maximum, which is 1\. Its graph is symmetrical with respect to the (½,0) point.The sin((k - .5)·π) function on the [0,1] interval (live).

Now let's get the desired codomain. If we add 1 to our function making it sin((k - .5)·π) + 1 this shifts out codomain up into the [0, 2] interval:

The sin((k - ½)·π) + 1 function on the [0,1] interval. At 0, the sin(((k - ½)·π) + 1 function is at its minimum, which is 0\. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1\. At 1, the sin((k - ½)·π) + 1 function is at its maximum, which is 2\. Its graph is symmetrical with respect to the (½,1) point.The sin((k - .5)·π) + 1 function on the [0,1] interval (live).

Dividing everything by 2 gives us the (sin((k - .5)·π) + 1)/2 function and compacts the codomain into our desired [0,1] interval:

The (sin((k - ½)·π) + 1)/2 function on the [0,1] interval. At 0, the (sin(((k - ½)·π) + 1)/2 function is at its minimum, which is 0\. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1\. At 1, the (sin((k - ½)·π) + 1)/2 function is at its maximum, which is 1\. Its graph is symmetrical with respect to the (½,½) point.The (sin((k - .5)·π) + 1)/2 function on the [0,1] interval (live).

This turns out to be a good approximation of the ease-in-out timing function (represented with an orange dashed line in the illustration above).

Comparison of all these timing functions

Let's say we want to have a bunch of elements with a linear-gradient() (like in the third demo). On click, their --stop values go from 0% to 100%, but with a different timing function for each.

In the JavaScript, we create a timing functions object with the corresponding function for each type of easing:

tfn = {
  'linear': function(k) {
    return k;
  }, 
  'ease-in': function(k) {
    return Math.pow(k, 1.675);
  }, 
  'ease-out': function(k) {
    return 1 - Math.pow(1 - k, 1.675);
  }, 
  'ease-in-out': function(k) {
    return .5*(Math.sin((k - .5)*Math.PI) + 1);
  }
};

For each of these, we create an article element:

const _ART = [];

let frag = document.createDocumentFragment();

for(let p in tfn) {
  let art = document.createElement('article'), 
      hd = document.createElement('h3');

  hd.textContent = p;
  art.appendChild(hd);
  art.setAttribute('id', p);
  _ART.push(art);
  frag.appendChild(art);
}

n = _ART.length;
document.body.appendChild(frag);

The update function is pretty much the same, except we set the --stop custom property for every element as the value returned by the corresponding timing function when fed the current progress k. Also, when resetting the --stop to 0% at the end of the animation, we also need to do this for every element.

function update() {
  let k = ++f/NF;  

  for(let i = 0; i  {
  if(rID) stopAni();
  dir *= -1;
  m = .5*(1 - dir);
  update();
}, false);

The final result can be seen in this Pen:

See the Pen by thebabydino (@thebabydino) on CodePen.

Even more examples

Gradient stops are not the only things that aren't animatable cross-browser with just CSS.

Gradient end going from orange to violet

For a first example of something different, let's say we want the orange in our gradient to animate to a kind of violet. We start with a CSS that looks something like this:

--c-ini: #ff9800;
--c-fin: #a048b9;
background: linear-gradient(90deg, 
  var(--c, var(--c-ini)), #3c3c3c)

In order to interpolate between the initial and final values, we need to know the format we get when reading them via JavaScript - is it going to be the same format we set them in? Is it going to be always rgb()/ rgba()?

Here is where things get a bit hairy. Consider the following test, where we have a gradient where we've used every format possible:

--c0: hsl(150, 100%, 50%); // springgreen
--c1: orange;
--c2: #8a2be2; // blueviolet
--c3: rgb(220, 20, 60); // crimson
--c4: rgba(255, 245, 238, 1); // seashell with alpha = 1
--c5: hsla(51, 100%, 50%, .5); // gold with alpha = .5
background: linear-gradient(90deg, 
  var(--c0), var(--c1), 
  var(--c2), var(--c3), 
  var(--c4), var(--c5))

We read the computed values of the gradient image and the individual custom properties --c0 through --c5 via JavaScript.

let s = getComputedStyle(document.body);

console.log(s.backgroundImage);
console.log(s.getPropertyValue('--c0'), 'springgreen');
console.log(s.getPropertyValue('--c1'), 'orange');
console.log(s.getPropertyValue('--c2'), 'blueviolet');
console.log(s.getPropertyValue('--c3'), 'crimson');
console.log(s.getPropertyValue('--c4'), 'seashell (alpha = 1)');
console.log(s.getPropertyValue('--c5'), 'gold (alpha = .5)');

The results seem a bit inconsistent.

Screenshots showing what gets logged in Chrome, Edge and Firefox.

Screenshots showing what gets logged in Chrome, Edge and Firefox (live).

Whatever we do, if we have an alpha of strictly less than 1, what we get via JavaScript seems to be always an rgba() value, regardless of whether we've set it with rgba() or hsla().

All browsers also agree when reading the custom properties directly, though, this time, what we get doesn't seem to make much sense: orange, crimson and seashell are returned as keywords regardless of how they were set, but we get hex values for springgreen and blueviolet. Except for orange, which was added in Level 2, all these values were added to CSS in Level 3, so why do we get some as keywords and others as hex values?

For the background-image, Firefox always returns the fully opaque values only as rgb(), while Chrome and Edge return them as either keywords or hex values, just like they do in the case when we read the custom properties directly.

Oh well, at least that lets us know we need to take into account different formats.

So the first thing we need to do is map the keywords to rgb() values. Not going to write all that manually, so a quick search finds this repo - perfect, it's exactly what we want! We can now set that as the value of a CMAP constant.

The next step here is to create a getRGBA(c) function that would take a string representing a keyword, a hex or an rgb()/ rgba() value and return an array containing the RGBA values ([red, green, blue, alpha]).

We start by building our regular expressions for the hex and rgb()/ rgba() values. These are a bit loose and would catch quite a few false positives if we were to have user input, but since we're only using them on CSS computed style values, we can afford to take the quick and dirty path here:

let re_hex = /^\#([a-f\d]{1,2})([a-f\d]{1,2})([a-f\d]{1,2})$/i,
    re_rgb = /^rgba?\((\d{1,3},\s){2}\d{1,3}(,\s((0|1)?\.?\d*))?\)/;

Then we handle the three types of values we've seen we might get by reading the computed styles:

if(c in CMAP) return CMAP[c]; // keyword lookup, return rgb

if([4, 7].indexOf(c.length) !== -1 && re_hex.test(c)) {
  c = c.match(re_hex).slice(1); // remove the '#'
  if(c[0].length === 1) c = c.map(x => x + x);
  // go from 3-digit form to 6-digit one
  c.push(1); // add an alpha of 1

  // return decimal valued RGBA array
  return c.map(x => parseInt(x, 16)) 
}

if(re_rgb.test(c)) {
  // extract values
  c = c.replace(/rgba?\(/, '').replace(')', '').split(',').map(x => +x.trim());
  if(c.length === 3) c.push(1); // if no alpha specified, use 1

  return c // return RGBA array
}

Now after adding the keyword to RGBA map (CMAP) and the getRGBA() function, our JavaScript code doesn't change much from the previous examples:

const INI = getRGBA(S.getPropertyValue('--c-ini').trim()), 
      FIN = getRGBA(S.getPropertyValue('--c-fin').trim()), 
      RANGE = [], 
      ALPHA = 1 - INI[3] || 1 - FIN[3];

/* same as before */

function update() {
  /* same as before */

  document.body.style.setProperty(
    '--c', 
    `rgb${ALPHA ? 'a' : ''}(
      ${INI.map((c, i) => Math.round(c + k*RANGE[i])).join(',')})`
  );

  /* same as before */
};

(function init() {
  if(!ALPHA) INI.pop(); // get rid of alpha if always 1
  RANGE.splice(0, 0, ...INI.map((c, i) => FIN[i] - c));
})();

/* same as before */

This gives us a linear gradient animation:

See the Pen by thebabydino (@thebabydino) on CodePen.

We can also use a different, non-linear timing function, for example one that allows for a bounce at the end:

const E = .8*Math.PI;

/* same as before */

function timing(k) {
  return Math.sin(k*E)/Math.sin(E)
}

function update() {
  /* same as before */

  document.body.style.setProperty(
    '--c', 
    `rgb${ALPHA ? 'a' : ''}(
      ${INI.map((c, i) => Math.round(c + timing(k)*RANGE[i])).join(',')})`
  );

  /* same as before */
};

/* same as before */

This means we go all the way to a kind of blue before going back to our final violet:

See the Pen by thebabydino (@thebabydino) on CodePen.

Do note however that, in general, RGBA transitions are not the best place to illustrate bounces. That's because the RGB channels are strictly limited to the [0,255] range and the alpha channel is strictly limited to the [0,1] range. rgb(255, 0, 0) is as red as red gets, there's no redder red with a value of over 255 for the first channel. A value of 0 for the alpha channel means completely transparent, there's no greater transparency with a negative value.

By now, you're probably already bored with gradients, so let's switch to something else!

Smooth changing SVG attribute values

At this point, we cannot alter the geometry of SVG elements via CSS. We should be able to as per the SVG2 spec and Chrome does support some of this stuff, but what if we want to animate the geometry of SVG elements now, in a more cross-browser manner?

Well, you've probably guessed it, JavaScript to the rescue!

Growing a circle

Our first example is that of a circle whose radius goes from nothing (0) to a quarter of the minimum viewBox dimension. We keep the document structure simple, without any other aditional elements.


For the JavaScript part, the only notable difference from the previous demos is that we read the SVG viewBox dimensions in order to get the maximum radius and we now set the r attribute within the update() function, not a CSS variable (it would be immensely useful if CSS variables were allowed as values for such attributes, but, sadly, we don't live in an ideal world):

const _G = document.querySelector('svg'), 
      _C = document.querySelector('circle'), 
      VB = _G.getAttribute('viewBox').split(' '), 
      RMAX = .25*Math.min(...VB.slice(2)), 
      E = .8*Math.PI;

/* same as before */

function update() {
  /* same as before */

  _C.setAttribute('r', (timing(k)*RMAX).toFixed(2));

  /* same as before */
};

/* same as before */

Below, you can see the result when using a bounce-fin kind of timing function:

See the Pen by thebabydino (@thebabydino) on CodePen.

Pan and zoom map

Another SVG example is a smooth pan and zoom map demo. In this case, we take a map like those from amCharts, clean up the SVG and then create this effect by triggering a linear viewBox animation when pressing the +/ - keys (zoom) and the arrow keys (pan).

The first thing we do in the JavaScript is create a navigation map, where we take the key codes of interest and attach info about what we do when the corresponding keys are pressed (note that we need different key codes for + and - in Firefox for some reason).

const NAV_MAP = {
  187: { dir:  1, act: 'zoom', name: 'in' } /* + */, 
   61: { dir:  1, act: 'zoom', name: 'in' } /* + Firefox ¯\_(ツ)_/¯ */, 
  189: { dir: -1, act: 'zoom', name: 'out' } /* - */, 
  173: { dir: -1, act: 'zoom', name: 'out' } /* - Firefox ¯\_(ツ)_/¯ */, 
   37: { dir: -1, act: 'move', name: 'left', axis: 0 } /* ⇦ */, 
   38: { dir: -1, act: 'move', name: 'up', axis: 1 } /* ⇧ */, 
   39: { dir:  1, act: 'move', name: 'right', axis: 0 } /* ⇨ */, 
   40: { dir:  1, act: 'move', name: 'down', axis: 1 } /* ⇩ */
}

When pressing the + key, what we want to do is zoom in. The action we perform is 'zoom' in the positive direction - we go 'in'. Similarly, when pressing the - key, the action is also 'zoom', but in the negative (-1) direction - we go 'out'.

When pressing the arrow left key, the action we perform is 'move' along the x axis (which is the first axis, at index 0) in the negative (-1) direction - we go 'left'. When pressing the arrow up key, the action we perform is 'move' along the y axis (which is the second axis, at index 1) in the negative (-1) direction - we go 'up'.

When pressing the arrow right key, the action we perform is 'move' along the x axis (which is the first axis, at index 0) in the positive direction - we go 'right'. When pressing the arrow down key, the action we perform is 'move' along the y axis (which is the second axis, at index 1) in the positive direction - we go 'down'.

We then get the SVG element, its initial viewBox, set the maximum zoom out level to these initial viewBox dimensions and set the smallest possible viewBox width to a much smaller value (let's say 8).

const _SVG = document.querySelector('svg'), 
      VB = _SVG.getAttribute('viewBox').split(' ').map(c => +c), 
      DMAX = VB.slice(2), WMIN = 8;

We also create an empty current navigation object to hold the current navigation action data and a target viewBox array to contain the final state we animate the viewBox to for the current animation.

`let nav = {}, tg = Array(4);`

On 'keyup', if we don't have any animation running already and the key that was pressed is one of interest, we get the current navigation object from the navigation map we created at the beginning. After this, we handle the two action cases ('zoom'/ 'move') and call the update() function:

addEventListener('keyup', e => {  
  if(!rID && e.keyCode in NAV_MAP) {
    nav = NAV_MAP[e.keyCode];

    if(nav.act === 'zoom') {
      /* what we do if the action is 'zoom' */
    }

    else if(nav.act === 'move') {
      /* what we do if the action is 'move' */
    }

    update()
  }
}, false);

Now let's see what we do if we zoom. First off, and this is a very useful programming tactic in general, not just here in particular, we get the edge cases that make us exit the function out of the way.

So what are our edge cases here?

The first one is when we want to zoom out (a zoom in the negative direction) when our whole map is already in sight (the current viewBox dimensions are bigger or equal to the maximum ones). In our case, this should happen if we want to zoom out at the very beginning because we start with the whole map in sight.

The second edge case is when we hit the other limit - we want to zoom in, but we're at the maximum detail level (the current viewBox dimensions are smaller or equal to the minimum ones).

Putting the above into JavaScript code, we have:

if(nav.act === 'zoom') {
  if((nav.dir === -1 && VB[2] >= DMAX[0]) || 
     (nav.dir ===  1 && VB[2] = DMAX[nav.axis] - VB[2 + nav.axis])) {
    console.log(`at the edge, cannot go ${nav.name}`);
    return
  }

  /* main case */

For the main case, we move in the desired direction by half the viewBox size along that axis:

else if(nav.act === 'move') {
  /* edge cases */

  tg[nav.axis] = VB[nav.axis] + .5*nav.dir*VB[2 + nav.axis]
}

Now let's see what we need to do inside the update() function. This is going to be pretty similar to previous demos, except now we need to handle the 'move' and 'zoom' cases separately. We also create an array to store the current viewBox data in (cvb):

function update() {  
  let k = ++f/NF, j = 1 - k, cvb = VB.slice();

  if(nav.act === 'zoom') {    
    /* what we do if the action is zoom */
  }

  if(nav.act === 'move') {    
    /* what we do if the action is move */
  }

  _SVG.setAttribute('viewBox', cvb.join(' '));

  if(!(f%NF)) {
    f = 0;
    VB.splice(0, 4, ...cvb);
    nav = {};
    tg = Array(4);
    stopAni();
    return
  }

  rID = requestAnimationFrame(update)
};

In the 'zoom' case, we need to recompute all viewBox values. We do this with linear interpolation between the values at the start of the animation and the target values we've previously computed:

if(nav.act === 'zoom') {    
  for(let i = 0; i  +c), 
      CPY_INI = DATA[3], 
      CPY_RANGE = 2*(DATA[1] - DATA[3]);

The rest is very similar to all other transition on click demos so far, with just a few minor differences (note that we use an ease-out kind of timing function):

/* same as before */

function timing(k) { return 1 - Math.pow(1 - k, 2) };

function update() {
  f += dir;

  let k = f/NF, cpy = CPY_INI + timing(k)*CPY_RANGE;  

  _FACE.setAttribute('rx', (timing(k)*RMAX).toFixed(2));
  _MOUTH.setAttribute(
    'd', 
    `M${DATA.slice(0,2)}
     C${DATA[2]} ${cpy} ${DATA[4]} ${cpy} ${DATA.slice(-2)}`
  );

  /* same as before */
};

/* same as before */

And so we have our silly result:

See the Pen by thebabydino (@thebabydino) on CodePen.