CSS-in-JS in 200 bytes

CSS-in-JS is a relatively new and a very contradictory way of writing CSS. On the one hand, it tries to overcome the limitations of traditional CSS to make it more reusable, especially when it comes to styling components. On the other hand, it’s different from traditional CSS and has it’s own disadvantages.

There is no single CSS-in-JS library, there is even no sight of a clear winner in this race. However, all CSS-in-JS implementations have a lot in common - they allow creating isolated reusable styles for components and use JavaScript as a language for writing styles.

Pros and cons

The most obvious benefit of using CSS-in-JS is that your styling is now connected very closely to your component - you remove the component and the related styles are gone, you modify the component - and you can be sure that it’s style depends only on what is declared in this very component, you don’t have to scan through all your CSS files to find the rule that unintentionally breaks all your styling. All styles belong to the individual component scopes.

Moreover, the newest CSS-in-JS libraries bring lots of power when it comes to dynamic styling - your CSS may depend on your component properties and even arbitrary variables in your render() routine. It can be a good thing, it can also be dangerously bad, but it’s definitely a powerful tool (and thus should be used with care).

However, everything comes at a price. If you go for CSS-in-JS - it means that you bring yet another layer into your tooling, yet another build step, yet another dependency. Also, some libraries have a very peculiar syntax which makes your CSS look neither like CSS nor like JS.

Now, can we build something that keeps the good parts and reduces the bad?

Meet zercss

This will be a run-time CSS injector. In other words, there will be extra build step to create or process CSS files.

In this case, the first thing to do is to inject a new stylesheet into the document:

const style = document.createElement('style');
style.setAttribute('style', 'text/css');
document.head.appendChild(style);
// We can inject custom CSS like this:
style.textContent = 'body { background: red; }';

This style node will be used to append CSS blocks of each style that will be defined in our JS code.

The dumbest possible implementation would be:

const css = s => style.textContent += s;

// Example:
css(`
* { margin: 0; padding: 0; }
body { font-family: 'Roboto', sans-serif; }
`);

This will inject parts of CSS into the global stylesheet, but it won’t be scoped CSS. To make it scoped we need to create random unique class-names for each CSS block, and return the class name so that it could be used in the component. For example:

let nextID = 0;
const css = s => {
  const id = 'z' + (++nextID);
  style.textContent += `.${id} { ${s} }`;
  return id;
};

// Create CSS blocks and generate class names ("z1" and "z2")
const outer = css(`display: grid; place-items: center;`);
const inner = css(`width: 100px; height: 100px;`);

// Create layout with the given class names
document.body.innerHTML = `
  <div class=${outer}>
    <div class=${inner}></div>
  </div>
`;

Now our CSS is kind of scoped, each block belongs to an individual class, but we can’t define more than one block of CSS at a time, and we can’t define additional selectors, such as :hover or styles for nested elements.

We can use a special symbol, for example & like Less or SASS do and replace it with the generate class name:

const css = s => {
  let id = 'z'+(++nextID);
  style.textContent += s.replace(/&/g, `.${id}`)
  return id;
};

const outer = css(`
  &       { background-color: red; }
  &:hover { background-color: blue; }
`)

However, this enforces writing a full CSS block with an ampersand selector every time, even if we want it to be short and implicit. Also, such brutal replacement opens another problem with ampersands that are not meant to be replaced (i.e. inside url() links or strings).

To overcome this, I suggest to make css() a named template literal. Such functions take an array of literal strings and evaluated fields to put in between. We can replace ampersands only inside the literal strings and pass evaluated fields as is. Additionally, we may keep track of how may ampersands have been replaced and wrap the contents into an implicit CSS block if no ampersand was found.

const css = (strings, ...values) => {
	let id = 'z'+(++nextID);
	let amp;
	let block = strings.map((s, i) => {
		const prefix = i ? values[i-1] : '';
		return prefix + s.replace(/&/g, () => amp = '.'+id);
	}).join('');

	if (amp) {
		// Return CSS block if at least one '&' was found
		style.textContent += block;
	} else {
		// Wrap implicit CSS into a block if '&' was not found
		style.textContent += `.${id} { ${block} }`;
	}
	return id;
};

Now if we minify this a little bit, we may get something like this:

let c,d=document,n=1,style=d.head.appendChild(d.createElement("style"));export let css=(e,...t)=>(c="z"+n++,d=0,e=e.map(((e,l)=>(l?t[l-1]:"")+e.replace(/&/g,(()=>d="."+c)))).join(""),style.textContent+=d?e:`.${c}{${e}}`,c);

Albeit its tiny size (~200 bytes), this code snippet is surprisingly powerful - it can handle media queries, keyframes, multiple selector and even global stylesheets. Just save this snippet as zercss.js and import it from your JS code like:

import {css} from './zercss.js';
const myStyle = css`
  color: red;
`
const MyDiv = () => <div className={myStyle}>Hello!</div>;

You may find the complete snippet and an example HTML page to test it in this gist: https://gist.github.com/zserge/b8d4fdfb544617ddf4df74c092063501

If you have any feedback or found a bug - please leave a comment there.

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Feb 08, 2021

See also: Let's make the worst React ever! and more.