Writing Your Own CSS-in-JS Library

Published on
Β·
Time to read
10 min read β˜•
Post category
tech

CSS-in-JS has been around for quite a while now. There are some valid reasons both for and against them, but we are not going to talk about those.

I have always believed that writing our own, simple version of something will help a lot to understand how something works, so let's try writing one!

Note ⚠️

The library we are going to write is by no means a production-ready one, but it still serves well as a way to learn about how CSS-in-JS might be implemented.

The CSSStyleSheet interface

Let's get to know the web API that will help us in our quest. The CSSStyleSheet API allows us to add style rules to the page. First, we would need to get a CSSStyleSheet instance. We can do this by creating a style element. We will also want to append this style element to the document.head so it affects the page.

const styleElement = document.createElement('style');
const sheet = styleElement.sheet;

document.head.appendChild(styleElement);

Here's a code doing the same thing, but shorter.

const sheet = document.head.appendChild(document.createElement('style')).sheet;

Now we have our CSSStyleSheet instance, what can we do with it? Well, the complete documentation about the interface can be looked at on MDN. For our case, we mostly care about cssRules and insertRule().

cssRules

This property returns a CSSRuleList containing all the rules inside this CSSStyleSheet. It is an array-like object, which means we can do some stuff we can do to an array, like accessing the .length property. Keep this in mind as we move on.

insertRule(rule, index)

This method inserts a rule into the specified index of the cssRules list. The rule is basically just a textual representation of a rule, for example:

.blue {
  color: blue;
}

That's it! Those are the 2 APIs we are going to work with as we write our own CSS-in-JS library. Now, let's start writing down our goal on how we want our library to look like.

The goal 🏁

We will work on these step-by-step, with each of the goal built upon the previous goal.

  1. Have a css() function that works

    const blueClass = css(`color: blue;`);
    
  2. Support tagged template literal

    const blueClass = css`
      color: blue;
    `;
    
  3. Vendor prefixing

  4. Server rendering support

Have a css() function that works

Let's start by writing down parts our css() function. We basically want it to receive a style rule string, and return a className, and it should insert the rule to the stylesheet in the process.

const sheet = document.head.appendChild(document.createElement('style')).sheet;

const css = (styleString) => {
  const className = getClassNameSomehow();

  sheet.insertRule(`.${className} { ${styleString} }`);

  return className;
};

As you can see, inserting the rule is pretty straightforward. Now, we need to have a way to generate a className. Some libraries generate hashes to be used as a className, but for our use case, let's keep it simple. We will use the sheet.cssRules.length, convert it to a string, and use that as a className. This is a simple way to ensure that all css() calls will return a different className, as the sheet.cssRules.length will keep increasing with each calls.

This is how our css() function looks like at this point.

const sheet = document.head.appendChild(document.createElement('style')).sheet;

const css = (styleString) => {
  // We use 36 as the radix here so that the output string uses the 26 alphabet characters as well.
  // Example:
  // a = 11;
  // a.toString(36); // => 'b'
  const className = `css-${sheet.cssRules.length.toString(36)}`;

  sheet.insertRule(`.${className} { ${styleString} }`);

  return className;
};

That's all for now! You can try it out on this sandbox to see it in action.

Support template literal

Alright, we have our basic stuff working so far! Now, we want to make it so we can write css with tagged template literal. If you are not familiar with it, basically we want to be able to write our css this way:

const blue = css`
  color: blue;
`;

This is actually a valid JavaScript! To understand what is actually going on with this code, try copy-pasting the following snippet into your browser console.

// just log all the arguments, so we understand what is going on
const foo = (...args) => console.log(args);

foo`what is happening?`;
// => [["What is happening?"]]

Huh, seems like it is just returning an array with our input string as the element. Let's see what happens if we introduce some injected variables inside the template literal.

// just log all the arguments, so we understand what is going on
const foo = (...args) => console.log(args);
const gerund = 'cooking';
const boom = 'BOOM!';

foo`You know what's ${gerund}? ${boom}`;
// => [ ["You know what's ", "? ", ""],  "cooking", "BOOM!"]

Now we are seeing something different! The first argument to foo is still an array of our string, but now it has multiple elements inside of it. If we take a look at it, it seems that each of the element is splitted when there is a variable injected at that point in the string.

So, it's basically the same thing as calling the foo function like this:

foo(["You know what's", '? ', ''], 'cooking', 'BOOM!');

Now that we know what is going on, we can write a function to construct them into a complete string. Let's call the function interleave(). We will also name the injected variables as interpolations.

const interleave = (strings, interpolations) => {
  let output = '';

  strings.forEach((s, i) => {
    output += s;
    output += interpolations[i] === undefined ? '' : interpolations[i];
  });

  return output;
};

// ...interpolations is using Rest Parameter syntax.
// Basically it collects [arg1, arg2, arg3..., argN] into a single array
const foo = (strings, ...interpolations) => interleave(strings, interpolations);

foo`You know what's ${gerund}? ${boom}`;
// => "You know what's cooking? BOOM!"

I hope that function makes it clear enough on how tagged template literals work. We now have a function to contruct them into a string, let's use that in our css() function.

const css = (strings, ...interpolations) => {
  const styleString = interleave(strings, interpolations);
  const className = `css-${sheet.cssRules.length.toString(36)}`;

  sheet.insertRule(`.${className} { ${styleString} }`);

  return className;
};

We can now use the tagged template literal with our css() function! See it in action in the following codesandbox.

Vendor prefixing

Some CSS properties need to have browser vendor prefixes for them to work. This could be because they are non-standard, more about this on MDN.

Usually, vendor prefixes are added at compile-time, using tools like autoprefixer. For our case though, we will do it on runtime using tiny-css-prefixer.

tiny-css-prefixer comes with a simple prefixProperty() function. We can utilise it in a helper function like this:

import { prefixProperty } from 'tiny-css-prefixer';

const prefix = (prop, value) => {
  const flag = prefixProperty(prop);
  let css = `${prop}: ${value};\n`;
  if (flag & 0b001) css += `-ms-${css}`;
  if (flag & 0b010) css += `-moz-${css}`;
  if (flag & 0b100) css += `-webkit-${css}`;
  return css;
};

prefix('writing-mode', 'auto');
// =>
// writing: auto;
// -ms-writing: auto;
// -webkit-writing: auto;
// -ms-writing: auto;

Our helper prefix() function can receive a CSS property and value and add the necessary vendor prefixes to it. Next, we are going to utililse this helper function into our css() function.

Note that tiny-css-prefixer doesn't add prefixes for all CSS properties; it only does that to those it finds necessary. This is good enough for our use case, especially if we don't particularly care about really old browsers.

Since our css() function accepts the style string as a whole, we will need to do some splitting to get each prop and value pairs. A way to do this would be to split by ; and : characters. We will do this in a getPrefixedStyleString() function.

Here is how our css() function looks like after the change.

import { prefixProperty } from 'tiny-css-prefixer';

const prefix = (prop, value) => {
  const flag = prefixProperty(prop);

  let css = `${prop}:${value};\n`;

  if (flag & 0b001) css += `-ms-${css}`;
  if (flag & 0b010) css += `-moz-${css}`;
  if (flag & 0b100) css += `-webkit-${css}`;

  return css;
};

const getPrefixedStyleString = (styleString) => {
  let temp = styleString
    .trim()
    .split(';')
    .map((s) => {
      const [prop, value] = s.split(':');

      if (prop && value) {
        return prefix(prop.trim(), value);
      }

      return prop || value;
    });

  return temp.join('');
};

const css = (strings, ...interpolations) => {
  const styleString = getPrefixedStyleString(
    interleave(strings, interpolations),
  );
  const className = `css-${sheet.cssRules.length.toString(36)}`;

  sheet.insertRule(`.${className} { ${styleString} }`);

  return className;
};

Nothing is different from the appearance, but if you look at your browser console, you should see the vendor prefixes added to the style string. Check out the following codesandbox.

Server rendering support

Finally, let's add a simple server rendering capabilities to the library. Basically, what we want to do is to have a way to extract the stylesheets and inject them into <style> tag in our HTML document. Since our whole implementation is based on the CSSStyleSheet API, which is not available in the Node.js environment, we will need to kind of mock it in Node.js environment.

Let's make a little change to the code that create our sheet.

const sheet =
  typeof window !== 'undefined'
    ? document.head.appendChild(document.createElement('style')).sheet
    : mockSheet();

Now, we need to implement mockSheet(). Remember that we pretty much only needed cssRules and insertRule, so let's make a simple version of them in our mocked sheet.

const mockSheet = () => {
  // the mock sheet need to have at least 2 properties
  // 1. cssRules: an array of rules in the sheet
  // 2. insertRule: a method to insert rules to the sheet
  // 3. extract: a method to return all the style strings
  const cssRules = [];

  return {
    cssRules,
    insertRule: (rule) => {
      cssRules.push(rule);
    },
    extract: () => cssRules.join(''),
  };
};

That's it! Now we can use our CSS-in-JS library in a server environment. Here is a sandbox showing it.

Closing

We have successfully written our own simple, < 1KB CSS-in-JS library. Sure, it doesn't really handle a lot of things yet, but it works! The important thing is that we (hopefully) have learned something during the process. If you are interested, you can try adding some more features to the libraries such as:

  1. keyframes() function
  2. global() function
  3. Better SSR support that can have separate sheet for each incoming HTTP requests

Hopefully you enjoyed reading this article and learned new stuff like I did!

Resources

I have published the code during this learning in the basic-css-in-js repo. You can take a look at the code to see how it works. Here's how the final APIs look like.

import { css, keyframes } from 'basic-css-in-js';

const Spinning = keyframes`
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
`;

const blueClass = css`
  color: blue;
  animation: ${Spinning} infinite 20s linear;
`;

const Component = () => {
  return <div className={blueClass}>I am blue and spinning</div>;
};

Here are some resources that I found useful during writing this article: