DEV Community

loading...

CSSOM - Why Houdini And The CSS Typed OM Is A Necessity

EmNudge
Web dev, voice actor, and water enthusiast. Explaining the complicated bits of JS.
Updated on ・7 min read

Let's start with the basics. What is CSSOM?
The OM in CSSOM stands for Object Model. It's how JS interfaces with CSS in the DOM.

You're probably familiar with CSSOM, but maybe not the name. It's this:

const div = document.querySelector('div');
div.style.marginLeft = '20px';
div.style["padding-right"] = '5px';
div.style.transform = 'translate(20px, 10px)';
Enter fullscreen mode Exit fullscreen mode

See? The CSS is represented in an Object Model which lets us edit values and see them reflected in real time. We can set styles using good ole JS camel case (marginLeft), or CSS kebab case (margin-left).

Messy CSSOM

The CSSOM is fine and dandy in most instances, but anyone who has worked with it for long enough knows that there are situations where the worse side rears its ugly head. Let's create some of those situations.

const div = document.querySelector('div');
div.style.position = 'relative';
div.style.left = '10' + (Math.random() > .5 ? 'px' : 'rem');
Enter fullscreen mode Exit fullscreen mode

Now we have moved our div either 10px or 10rem to the right. Let's say we want to move it 10 additional units to the right, regardless of the unit type.

const num = parseFloat(div.style.left) + 10;
const unit = div.style.left.slice(String(num).length);
div.style.left = num + unit;
Enter fullscreen mode Exit fullscreen mode

At first I'm using a parseFloat trick to just get the number, but when we want to append the unit, it gets a bit trickier. Since rem, unlike most other unit values, has a length of three, we're using a different method which can handle any unit length.

It works, but it's definitely not clean or very predictable. Let's show another case.

const div = document.querySelector('div');
const randLightness = Math.floor(Math.random() * 50) + 30; // between 30 and 79
div.style.background = `hsl(100, 100%, ${randLightness}%)`;
Enter fullscreen mode Exit fullscreen mode

Now let's retrieve the color and get its lightness value.

const lightness = div.style.background.split(',')[2].slice(1, -1);
console.log('lightness: ' + lightness); // > "lightness: 0"
Enter fullscreen mode Exit fullscreen mode

That's weird. We put in a lightness of 30% - 79% and got back "0". There's no percentage either. I know my JS is right. I think so, at least. Let's console log the color and see what we're getting.

console.log(div.style.background) // > "rgb(68, 204, 0)"
Enter fullscreen mode Exit fullscreen mode

What? We put in an hsl() format. We seem to be getting back an rgb() representation of hsl(100, 100%, 40%). If we want to increase the lightness by ten, we're going to have to try a much different method than last time.

CSSOM's Hazardous Consistencies

I touched on this in a previous article of mine surrounding a FireFox problem with transform: translate().

The CSSOM has a spec which describes how it handles values passed into it. It will convert any color format into rgb() when it can. It's specified as follows:

If is a component of a resolved or computed value, then return the color using the rgb() or rgba() functional notation as follows

The "as follows" part isn't very interesting aside from how it specifies that if you pass rgb() an alpha parameter, it will convert it to rgba(). What's important is how it will always return a color in rgb() or rgba() format.

This is horrible if we want to edit a property using the hsl() format. We'd have to use complex math to convert it to hsl() before editing and sending it back.

The one way to prevent a lot of hassle is to use a CSS variable in place of one of the parameters. This prevents the engine from parsing it, since that would lead to much different results.

div.style.background = 'hsl(100, 100%, calc(var(--lightness) * 1%))';
const randLightness = Math.floor(Math.random() * 50) + 30; // between 30 and 79
div.style.setProperty('--lightness', randLightness);
Enter fullscreen mode Exit fullscreen mode

NOTE: You must use .setProperty to edit CSS variables. Setting it on the style property itself using style['--lightness'] will not work. You can verify this by logging div.style.cssText when setting either.

But using CSS variables seems more like a patch than a solution. The CSS itself looks a bit messy and if a third party wanted to edit our CSS they'd have to recognize the existence of the CSS variable in order to make any changes.

The CSSOM makes a lot of changes like these. You can play around and see what gets changed by going to this site by Tom Hodgins. It's a fantastic little tool that lets you see how the engine parses your CSS in real time. It's primarily used to see if your browser supports a specific feature, but this is an excellent use case as well.
Nov 21, 2019 edit: About 5 minutes ago, we have come under the realization that he is CSSTOM Hodgins. Please address him as such in future encounters.

This CSSOM behavior gets even messier when changing values of css properties that accept multiple parameters.

CSSOM's Hazardous Cleanup

According to #2.2 in section 6.7.2 - Serializing CSS Values in the spec:

If component values can be omitted or replaced with a shorter representation without changing the meaning of the value, omit/replace them.

If possible, CSS values are cleaned up by replacing and omitting parts to make them shorter. This seems like a good idea in theory, but when passing values that can be shortened, they almost always will.

Using the same tool mentioned in last section, we can verify that margin: 1px 1px; will be shortened to margin: 1px; in every browser, but transform: translate(20px, 0px); will only be shortened to transform: translate(20px); in firefox.

Each of these shortenings have the exact same functionality in all browsers, but sometimes only one browser will choose to shorten it.

Due to the spec, this issue with FireFox was declared not to be a bug in response to a report on Bugzilla.

As stated [on the spec], we may change this for compatibility with other engines, but I think lacking broken sites, the Gecko behavior is preferable / more consistent / more correct.

This all means that when we grab values from CSSOM, we have to be wary that it might not contain all the parameters we originally passed into it. This leads to a lot more logic and messy-looking code each time.

Luckily, the Houdini initiative aims to solve all of this. It currently does not address all of our problems, but hopes to soon do so.

Introducing CSS Typed OM

Like how JS has the types 'String', 'Number', 'Boolean', etc, CSSOM is getting its mini type system as well. They are accessible through the global CSS variable and are used as follows:

const div = document.querySelector('div');
div.attributeStyleMap.set('margin-left', CSS.px(23));
const margin = div.attributeStyleMap.get('margin-left')
console.log('margin-left: ' + margin); // > "margin-left: 23px"
Enter fullscreen mode Exit fullscreen mode

Now this is certainly more verbose and CSS Typed OM will often be more verbose, but the typed system is a lot more safe, as types usually are.

The new way to access styles is through a Map-like object called attributeStyleMap instead of just style. Like Maps, it has all the usual methods: get, set, has, etc. It's also an iterator, so it's loopable in a for ...of.

When retrieving the style, a bit of formatting goes on. You get an object containing a value and unit property. When calling the .toString() method, you get a concatenated string instead of an object. Concatenating the unit object will call the .toString() method, which is why the example above didn't include it.

How does this solve our problems? Let's start with one of the basic principles:
What you put in is what you get back. It does a lot of formatting for us so that our CSS is valid, but we can still retrieve what we put in.
Let's set up an example:

const div = document.querySelector('div');
div.attributeStyleMap.set('z-index', CSS.number(4.45143));
Enter fullscreen mode Exit fullscreen mode

What we have here is a z-index with a really peculiar z-index. Let's say that we're animating our z-index in a very specific fashion, so these decimal places are important to us.
CSSTOM will format this for us and truncate off the decimals, but when we request it later, our decimals will be preserved

div.attributeStyleMap.get('z-index').values[0].value;  // -> 4.45143
div.computedStyleMap().get('z-index').value;           // -> 4
Enter fullscreen mode Exit fullscreen mode

Note 1: We can get the parsed value using .computedStyleMap() instead of .attributeStyleMap.

Note 2: I used .values[0].value instead of .value since at the time of writing this article, there seems to be a bug where decimal values in CSSTOM z-index will generate a CSSMathSum object.

This behavior will in the future (I got confirmation from Tab Atkins Jr.) also extend to colors. The value will still be parsed into rgb() format, but we will be able to retrieve our hsl() representation.

Transform functions are also much more organized. We are additionally going to be getting a separate translate and scale css property for even better access in the future.

CSSTOM Now

At the time of writing this article, CSS Typed OM is only available in part on Chrome (Chromium, so Opera too and soon Edge) exclusively. The spec itself is still being written, so it may be some time before we see a full implementation.

In my opinion, CSSTOM is the only Houdini initiative that really fixes something. The other features coming along are absolutely fantastic as well, but more about additions than fixes. CSSTOM cannot come any sooner.

Discussion (0)