DEV Community

Cover image for The Hazards of Passing CSS Through JS - A Look Into CSS Transform and Inconsistencies
EmNudge
EmNudge

Posted on • Edited on

The Hazards of Passing CSS Through JS - A Look Into CSS Transform and Inconsistencies

I love CSS. It's my baby. I will cherish CSS forever, but let's be honest. It's a bit of a mess.

Through the years, backwards compatibility and browser support has made CSS (and JS, but that's not what this article is about) a bit messy. While different browser engines won't always affect the outcome of some JS, only the speed, the same can't be said for CSS.

Different engines have different ways of handling styles. It's rather subtle, but it ends up leading to really annoying bugs later on in the development process. One specifically fickle CSS property is the CSS transform.

It can be used to move and manipulate elements. Yair Even Or made this neat codepen to see just what transforms are capable of: https://codepen.io/vsync/pen/RayMgz

It's a bit odd since, similar to the filter property, it doesn't really do just one thing. It can be used for a multitude of things. It transforms them. How? That's up to you.

transform: translate(10px, 20px); /* moves to the x,y coords of (10, 20) */
transform: rotate(15deg); /* rotates 15 degrees clockwise */
Enter fullscreen mode Exit fullscreen mode

You may often find yourself needing more than one transform at a time. Listing them like this will overwrite the previous one. We're instead able to combine them.

/* moves to the x,y coords of (10, 20) and rotates 15 degrees clockwise */
transform: translate(10px, 20px) rotate(15deg); 
Enter fullscreen mode Exit fullscreen mode

Got it? Cool. It's only uphill from here... Right?

The Good

Transforms are real nice since they don't require any layout-based calculations. Paul Irish makes a nice case for using transform: translate(10px, 10px) over absolute positioning using left: 10px and right: 10px with position: absolute;.

Transforms are not only better for performance, they allow for sub-pixel positioning. You can move an element to the position of (20.3423, 4.5294) without any errors. Why would you want this? Cursors move using sub-pixels. If you want any drag interaction, using absolute positioning might do what's referred to as "dubstepping." I highly recommend checking out Paul Irish's article for more information on that.

Now, despite the upsides, the complex nature of transform leads to a lot of annoyances. One such annoyance is regarding the dreaded blur.

Blur

Since transforms are calculated differently behind the scenes, the way the elements are displayed at the end of the day can also be affected. During transform animations, the element may experience a slight amount of blur. Since we're dealing with sub-pixels, this is understandable. It's annoying, but understandable.

Unfortunately, this behavior is super inconsistent. Not only between browsers, but within the same browser in almost identical environments. Chrome seems to be the biggest culprit. It's difficult to reproduce blur, but it will usually show up during transform animations. I first noticed this a while back when working on an idea I had: a website for microphone comparisons.

You can see the early prototype here, but notice how the microphone boxes grow a bit on hover. Transform is just about the only way to achieve such an effect using transform: scale(), as simple as it is, but I ran into a problem. On some browsers, on some operating systems, sometimes the boxes would blur during or even after the animation.

I ended up with a clever solution where the boxes are initially scaled down and only resize back to normal on hover. This would ensure the box wasn't resting on any sub-pixels when hovered over. If it was smaller and blurry it didn't matter as much - only if it blurred when enlarged.

Although, this only fixed it most of the time. I soon forgot all about this and was only reminded of it when working on a project involving draggable windows.

Here is where it gets problematic. We're manipulating transforms through JS.

Manipulating CSS With JS

Let's say we'd like to change the padding of an element to 5px more than what it originally was. Assuming this padding is on an element with the class of box, we'd probably do something like this:

const box = document.querySelector('.box');
const padding = parseInt(box.style.padding, 10);
box.style.padding = padding + 5 + 'px';
Enter fullscreen mode Exit fullscreen mode

We're getting the box, getting its padding px value as a number, and then giving it back with 5 more, tacking on the 'px' so that it knows which unit type we're using. A bit much, but bearable.

As a side note, we're using a trick with parseInt(). Using Number() would return a NaN since it includes the letters px. parseInt() will get us the number until it reaches a string. It will only ever return a NaN if the first character isn't parsable. feeding it a px string will give us the number until the px.

Now let's try adding 10px to the top and bottom and 20px to the sides. Why? Just for the hell of it.

const box = document.querySelector('.box');
const [paddingY, paddingX] = box.style.padding.split(' ').map(val => parseInt(val, 10));
box.style.padding = `${paddingY + 10}px ${paddingX + 20}px`;
Enter fullscreen mode Exit fullscreen mode

Woah. We were able to use the same amount of lines, but now it's a bit more complicated. We're assuming the CSS already had a padding of 2 values, separated by a space, but it might not have. We could have passed parseInt without a radix, shortening the map to just .map(parseInt), but it's bad practice. We also used array destructuring to save some more space.

Now let's move on to CSS transforms. Let's handle a very real case of moving a box 10.513 more pixels to the right and 3.422 more pixels upward. We'd get such a situation with draggable boxes using CSS transforms.

const box = document.querySelector('.box');
const matchTranslate = new RegExp(/translate\(.+?\)/);
const translateStr = box.style.transform.match(matchTranslate)[0];
const translatePos = translateStr.slice(10, -1).split(',');
const [x, y] = translatePos.map(val => parseFloat(val, 10));
const newTransStr = `translate(${x + 10.513}px, ${y - 3.422}px)`
box.style.transform = translateStr.replace(matchTranslate, newTransStr);
Enter fullscreen mode Exit fullscreen mode

Ouch. Regex? Yup. We could do this with a few higher order functions, but regex is going to be faster and more clear (Regex? Clear? What?).

Since the transform property can include multiple transforms we have to find and return our transform without touching the others. Our values are also contained within transform(${rightHere}) so we have to dig a bit to get at them before we can even parse them into numbers using parseFloat (it's the same as parseInt, but for decimals now. Transforms deal with sub-pixels, remember?).

Now here comes some more frustration, which is one of the reasons I sat down to write this article. There are some things that different browsers do with transforms that aren't outlines very well in any documentation.

Optional Values and CSS Parsing

You see, translate's second value is optional. Only adding one px value is valid. It will simply just move in the x direction. Note that this is different than scale(), which also has a second option value, but acts differently.

If scale()'s second value is left out, it will scale proportionally in both directions. It's the same deal with properties like the aforementioned padding. Leave one value out and it'll pad in all 4 directions. translate(), however, will move exclusively on the x axis.

It's a bit weird, but nothing too odd, right? Not exactly.
We discussed weird browser behavior before. Here is another one to add onto the pile.

When manipulating CSS values in JS, the browser might clean up the values a bit before using them. If you set the transform of your box to translate(20px, 1px), when retrieving your value, you will find all those spaces condensed. It will return as a much cleaner translate(20px, 1px).

Again, odd-ish, but nothing to worry about just yet. Here's where it gets interesting.

Since only one value passed to translate() will move it in the X direction, what happens if we pass it translate(20px, 0px)? In this case, it's identical to translate(20px). Will the browser clean it up? Well, it depends. On Chrome, Edge, and Safari it won't, but on Firefox it will. What happens if we run our previous code when out draggable box is on the position (20, 0)? There shouldn't be any problem... right?

Unfortunately, our code gets condensed. Our y value will be undefined and any math we do will result in a NaN. This means our y position will from now on be stuck whenever it hits 0, but only in FireFox.

Since this information isn't exactly listed anywhere at surface level, finding this was a pain. How can we prevent something like this from creeping up on us in the future?

Solution 01 - Local State

The most obvious one is probably storing all our changing css values as state. If we keep a local state of all our current css values, we can automatically generate a new css string when we need one.

let skew = 0,
    translateX = 0,
    translateY = 0,
    rotate = 0;

function getTransform(skew, translateX, translateY, rotate) {
    return `skew(${skew}deg) translate(${translateX}px, ${translateY}px) rotate(${rotate}deg)`
}
Enter fullscreen mode Exit fullscreen mode

This makes it much easier to change the transform. We don't need to care about the positions of anything or what the browser returns. We're not getting anything, only every setting.

Now it happens to be that I was forbidden from keeping any local state in my specific example. The specific are irrelevant, but if such a case comes up, we're not gonna have a very fun time.

Let's try another method

Solution 02 - CSS Variables

CSS variables have support across all major browsers (excluding IE of course). The major benefit of native CSS variables over pre-compiled/transpiled variables like ones from Sass or Less, is that CSS variables can be accessed and altered through JS.

We can define CSS variables in our transform example as follows.
Credit goes to Tommy Hodgins for this idea.

.box {
  /* ...other styles */
  --pos-x: 0;
  --pos-y: 0;
  transform: translate(
    calc(var(--pos-x) * 1px),
    calc(var(--pos-y) * 1px)
  );
}
Enter fullscreen mode Exit fullscreen mode

Using this syntax, we have set up 2 CSS variables named --pos-x and --pos-y. These variables are passed through the translate() function and turned into pixel values by using calc(). By multiplying the values by 1px, we are effectively converting them into pixels from whatever they were beforehand. Look Ma! No JS!

Whenever we want to manipulate the position, we just write some JS such as

const box = document.querySelector('.box');
const pos = { x: 20, y: 75 };
box.style.setProperty("--pos-x", pos.x);
box.style.setProperty("--pos-y", pos.y);
Enter fullscreen mode Exit fullscreen mode

and voila! Our box will have moved!
We didn't have to extract the values in any weird way, write tons of lines of code, or deal with any unit conversion in the JS. The CSS does most of the work for us!

This would probably be the optimal solution for most circumstances. The problem comes when you want to start manipulating many values and switching over to the CSS to create new variables becomes a bit cumbersome. While not completely available yet, the Houdini effort is on its way to fully introduce the CSS Typed OM.

Solution 03 - CSS Typed Object Model

Eric Bidelman gave a nice write up on what it is. I highly recommend reading up over on google developers about it.

Essentially, it's an Object API for CSS. It lets us interact with the CSS in a much cleaner way. Instead of getting strings, we're getting nested objects that are organized according to what we're getting and setting.
Here's an example straight from Eric's post (with some edits):

const box = document.querySelector('.box');
box.attributeStyleMap.set('padding', CSS.px(42));
const padding = el.attributeStyleMap.get('padding');
console.log(padding.value, padding.unit); // 42, 'px'
Enter fullscreen mode Exit fullscreen mode

Here we are setting and then getting the padding. It's a bit more verbose, but it is now much clearer what we're doing. I wanted to show an example using transform, but the support for CSSOM seems to be too weak right now. CSSOM is a part of the Houdini initiative and only has support in Chrome from version 66. The MDN docs are fairly sparse and its use with properties such as transform and filter which can contain multiple styling options is fairly poor. For now.

The Houdini effort in general is fantastic and its introduction is much needed.

Well, while we're at a bit of an impasse, there is yet another option

Top comments (0)