2
\$\begingroup\$

I've been looking for quite some time to fix the wobbly spinner issue. Many people made numerous suggestions using fonts / pure CSS / CSS + JS. Unfortunately, none of the proposed solutions were acceptable to me.

The problem with fonts like FontAwesome is that the icon has to be perfectly centered in a container for it not to wobble. Perfect centering with fonts is simply unachievable, since different browsers will render fonts slightly different.

The problem with pure CSS is that most solutions use a font-size and em units to scale spinner elements. em units deliver fractional widths and once again different browsers will yield different results for fractional calculations, resulting in a not perfectly centered spinner.

Even when you build a perfectly centered spinner using javascript, you will notice the wobbly effect at various spinner sizes, while stable at other sizes. After some research, I found the native rotate function also suffers from dealing with fractional dimensions at different scaling.

I ended up building a JavaScript spinner with varying opacity rather than rotating the spinner container, which seems to work wobble free at any modern browser / size / scale. The opacity approach makes background colors blend with the spinner and you can easily change the dimensions or opacity for each spinner hand in the opacity map. See demo.

While this is the best solution I've seen so far, I was wondering if there's a more lightweight approach yielding the same result.

function spinner(size) {
    var s=size,n=document.createElement('i'),n1=document.createElement('i'),n2=document.createElement('i'),map=[0.8,0.7,0.6,0.5,0.2,0.2,0.2,0.2];
    n.style.cssText='position:relative;display:inline-block;width:'+s+'px;height:'+s+'px;border-radius:100%;margin:20px';
    n1.style.cssText='position:absolute;top:0;left:50%;display:block;width:'+(s/8)+'px;height:'+s+'px;margin-left:'+(-s/16)+'px;border-radius:'+(s/8)+'px;opacity:'+map[0];
    n2.style.cssText='display:block;width:100%;height:30%;background:#000;border-radius:'+(s/8)+'px';
    n1.appendChild(n2);n.appendChild(n1);
    for (var j=1;j<8;j++){var i=n1.cloneNode(true),r='rotate('+(-j*45)+'deg)';i.style.cssText+='opacity:'+map[j]+';transform:'+r+';-webkit-Transform:'+r+';-moz-Transform:'+r+';-ms-Transform:'+r;n.appendChild(i)}
    var f=n.childNodes,a=map.slice(0),b,timer=setInterval(function(){for(j=0;j<f.length;j++){f[j].style.opacity=a[j]};b=a.shift();a.push(b)},125);document.body.appendChild(n);
}

Demo

Multi-color blending demo

As epascarello pointed out, JavaScript timers like setInterval should be avoided due to their single-threaded nature. Instead of using the suggested requestAnimationFrame I refactored the code using native CSS3 animation and it does appear to be slightly smoother. I also fitted the function with an invert option in order to easily switch between light and dark background colors. To create a 24px spinner: call spinner(24) on a light background and spinner(24,1) on a dark background, as you can see in the demo.

Is there still a better way to create the desired effect?

window.onload=function(){document.body.style.background='#888';spinner(12);spinner(16,1);spinner(37);spinner(51,1);spinner(64)}
function spinner(size,invert) {
    var s=size,c='#000',n=document.createElement('i'),n1=document.createElement('i'),n2=document.createElement('i'),y=document.createElement('style'),map=[0.8,0.7,0.6,0.5,0.2,0.2,0.2,0.2,0.8];if(invert){c='#fff'};
    n.style.cssText='position:relative;display:inline-block;vertical-align:middle;width:'+s+'px;height:'+s+'px;border-radius:100%;margin:20px';
    n1.style.cssText='position:absolute;top:0;left:50%;display:block;width:'+(s/9)+'px;height:'+s+'px;margin-left:'+(-s/18)+'px;border-radius:'+(s/9)+'px;opacity:'+map[0]+';animation:spin 1s infinite steps(8);-webkit-animation:spin 1s infinite steps(8)';
    n2.style.cssText='display:block;width:100%;height:29%;background:'+c+';border-radius:'+(s/9)+'px';n1.appendChild(n2);n.appendChild(n1);
    for (var s=[],j=0;j<=8;j++){s.push((j*12.5)+'%{opacity:'+map[j]+'}');if(j>6){continue};var i=n1.cloneNode(true),r='rotate('+((j+1)*45)+'deg)',d=(j+1)*125;i.style.cssText+='-webkit-animation-delay:'+d+'ms;animation-delay:'+d+'ms;opacity:'+map[j]+';transform:'+r+';-webkit-Transform:'+r+';-moz-Transform:'+r+';-ms-Transform:'+r;n.appendChild(i)};
    y.type='text/css';y.textContent='@-webkit-keyframes spin {'+s.join(' ')+'} @keyframes spin {'+s.join(' ')+'}';document.getElementsByTagName('head')[0].appendChild(y);document.body.appendChild(n);
}

JS demo

CSS+JS demo

\$\endgroup\$
  • 3
    \$\begingroup\$ An animated gif wouldn't suffice? You could include as a background-image in your stylesheet, or even lazy load the image depending on if you're using Ajax. \$\endgroup\$ – dward Jul 27 '15 at 16:10
  • 2
    \$\begingroup\$ The idea is to use a resolution independent and lightweight spinner. Animated gifs medieval in my opinion, so I prefer more 'modern' solutions. \$\endgroup\$ – Sauvage Jul 27 '15 at 16:22
  • 2
    \$\begingroup\$ Medieval, but ideal for this scenario. Why don't you make something that gifs can't handle then, like smooth animation or something? \$\endgroup\$ – Shomz Jul 28 '15 at 11:15
  • 1
    \$\begingroup\$ FYI: setInterval is bad and can be choppy. hacks.mozilla.org/2011/08/… \$\endgroup\$ – epascarello Jul 28 '15 at 12:55
  • 1
    \$\begingroup\$ Holy compacted code. My eyes! \$\endgroup\$ – Carcigenicate Aug 12 '15 at 15:32
1
\$\begingroup\$

As it stands, your code is pretty much unreadable. It is easier to read the minified version of jQuery than that. Yes, it's a mish-mash of super-clunky almost-minified unreadable code.

I will review the little I can understand:

  1. Your function does too much!

    It appears to have the following functionalities:

    • Stylesheet
    • Inline-stylesheet
    • Element creation
    • Some other obscure functionality

    This function should do 1 thing and do it well, not a bunch of them and doing it ok-ish.

  2. The already-mentioned readability

  3. Variable names should be descriptive.

    Example of variables you have:

    • s - size is already perfect
    • c - color is a candidate, but I don't know where you use it
    • n - ...
    • n1 - ...
    • n2 - I have no idea
    • y - Y U NO USE DESCRIPTIVE NAMES?

    I decided to stop there.

  4. You don't validate if size is a number.

    What would happen if I run spinner('testing')? And spinner('99999999999999')?

    It continues executing, without any validation what-so-ever.

    I expect it to throw an InvalidArgument exception or RangeError or something.

\$\endgroup\$
  • \$\begingroup\$ I think you misunderstood the question. This is about finding the best way to produce a flexible and lightweight spinner. Validation has been left out on purpose since it can easely be fitted in the final candidate. Concerning readability: it's pretty straightforward. n is obviously used for node and n(0-99) for child nodes. y is used for style etc. There is nothing obscure about the code: there is element creation, style creation and finally animation on the created object. \$\endgroup\$ – Sauvage Aug 14 '15 at 9:11
  • \$\begingroup\$ @Sauvage The whole code is a mish-mash of almost-minified stuff. And, I didn't. I understood pretty well the question. Also, reviewers may comment on any part of the code. I simply picked what I could read. \$\endgroup\$ – Ismael Miguel Aug 14 '15 at 9:15
  • \$\begingroup\$ Please take a look at my new and improved answer. \$\endgroup\$ – Sauvage Aug 14 '15 at 12:56
0
\$\begingroup\$

After investigating all possible ways to create a lightweight and flexible spinner, I ended up using requestAnimationFrame which is quite brilliant. It basically does the same thing as CSS3 animation: perform calculations and hand the result off to the browser in order to sync repaint with screen redraw (typically at 60fps). While CSS3 transition and animation are suitable for very basic usage since there's only a transitionend event which may not fire under certain circumstances, requestAnimationFrame offers full control since you can perform multiple complex calculations and stay in sync with screen redraw.

CSS

i.spinner {position:relative;display:inline-block;margin:20px}
i.bar {display:block;position:absolute;top:0;left:50%;height:inherit}
i.bar i {display:block;width:100%;height:29%;background:#000}
i.bar:nth-child(2) {transform:rotate(45deg);-webkit-Transform:rotate(45deg);-moz-Transform:rotate(45deg);-ms-Transform:rotate(45deg)}
i.bar:nth-child(3) {transform:rotate(90deg);-webkit-Transform:rotate(90deg);-moz-Transform:rotate(90deg);-ms-Transform:rotate(90deg)}
i.bar:nth-child(4) {transform:rotate(135deg);-webkit-Transform:rotate(135deg);-moz-Transform:rotate(135deg);-ms-Transform:rotate(135deg)}
i.bar:nth-child(5) {transform:rotate(180deg);-webkit-Transform:rotate(180deg);-moz-Transform:rotate(180deg);-ms-Transform:rotate(180deg)}
i.bar:nth-child(6) {transform:rotate(225deg);-webkit-Transform:rotate(225deg);-moz-Transform:rotate(225deg);-ms-Transform:rotate(225deg)}
i.bar:nth-child(7) {transform:rotate(270deg);-webkit-Transform:rotate(270deg);-moz-Transform:rotate(270deg);-ms-Transform:rotate(270deg)}
i.bar:nth-child(8) {transform:rotate(315deg);-webkit-Transform:rotate(315deg);-moz-Transform:rotate(315deg);-ms-Transform:rotate(315deg)}

JS

function buildspinner(size, invert) {
  var color = '#000',
    spinner = document.createElement('i'),
    bar = document.createElement('i'),
    hand = document.createElement('i'),
    opacitymap = [0.8, 0.2, 0.2, 0.2, 0.2, 0.5, 0.6, 0.7],
    nodemap = [];
  if (invert) {color = '#fff'};
  spinner.className = 'spinner';
  spinner.style.cssText = 'width:' + size + 'px;height:' + size + 'px';
  bar.className = 'bar';
  bar.style.cssText = 'width:' + (size / 9) + 'px;height:' + size + 'px;margin-left:' + (-size / 18) + 'px';
  hand.style.cssText = 'border-radius:' + size + 'px;background:' + color;
  bar.appendChild(hand);
  for (var j = 0; j < 8; j++) {
    var clone = bar.cloneNode(true);
    clone.style.opacity = opacitymap[j];
    spinner.appendChild(clone);
    nodemap.push(clone)
  }
  document.body.appendChild(spinner);
  requestAnimationFrame(function(timestamp) {animatespinner(timestamp, timestamp, 125, opacitymap, nodemap, 0)})
}

function animatespinner(starttime, timestamp, duration, opacitymap, nodemap, counter) {
  var progress = (timestamp - starttime) / duration;
  counter++;
  if (counter % 3 == 0) {
    for (var j = 0; j < 8; j++) {
      var next = j - 1;
      if (next < 0) {
        next = 7
      };
      nodemap[j].style.opacity = (opacitymap[j] + (opacitymap[next] - opacitymap[j]) * progress)
    }
  }
  if (progress < 1) {
    requestAnimationFrame(function(timestamp) {animatespinner(starttime, timestamp, 125, opacitymap, nodemap, counter)})
  } else {
    var rotatearray = opacitymap.pop();
    opacitymap.unshift(rotatearray);
    requestAnimationFrame(function(timestamp) {animatespinner(timestamp, timestamp, 125, opacitymap, nodemap, 0)})
  }
}

The counter variable is used for throttling. You want the animation to be smooth, but you want to keep CPU usage low. In this example we change opacity every 3 frames instead of every frame, heavily reducing CPU overhead wihtout noticeable effect on smoothness. (CPU usage was reduced from 12% to 5% on a Quadcore 3GHz processor).

Because CSS3 animation relies on keyframes you would have to create a separate keyframe for each spinner hand, resulting in way too much calculations. The same spinner built with CSS3 animation resulted in 30% CPU usage.

Demo

\$\endgroup\$
  • \$\begingroup\$ For some weird reason, this one causes 17-20% CPU usage on IE11, while codepen.io/anon/pen/WvYzaK?editors=011 causes only 8-11%. \$\endgroup\$ – Ismael Miguel Aug 14 '15 at 13:13
  • \$\begingroup\$ Weird indeed. On webkit it's vice versa. \$\endgroup\$ – Sauvage Aug 14 '15 at 13:19
  • \$\begingroup\$ But then again, IE has never been a good sport when it comes to honouring standards ?! \$\endgroup\$ – Sauvage Aug 14 '15 at 16:00
  • \$\begingroup\$ @Ismael Miguel: how's readability working for you now ? \$\endgroup\$ – Sauvage Aug 19 '15 at 10:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.