Writing Your Own CSS-in-JS Library
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.
Have a
css()
function that worksconst blueClass = css(`color: blue;`);
Support tagged template literal
const blueClass = css` color: blue; `;
Vendor prefixing
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:
keyframes()
functionglobal()
function- 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: