The evolution of scalable CSS is a multi-part chronicle intended to document the progress of tools, practices and techniques that enable us to write maintainable CSS at scale.
- Introduction
- Part 1: CSS scalability issues
- Part 2: Good practices
- Part 3: CSS Processors
- Part 4: Methodologies and Semantics
- Part 5: Styles Encapsulation
- Part 6: Atomic CSS
- Part 7: CSS-in-JS
- Part 8: Type-safe CSS
- Epilogue
In the previous part of this series, Part 6: Atomic CSS, we've covered an alternative paradigm to CSS authoring, using a predefined set of single-purpose utility classes that we reference in HTML. This paradigm removes the majority of scalability issues associated with Semantic CSS.
During this part we'll cover a third paradigm, introduced around the same period as Atomic CSS. Instead of using .css
files for style definition, CSS-in-JS moves the definition inside .js
files, colocating the component's styles with its markup and behavior.
We won't be analyzing any particular library as their APIs differ quite a bit. Instead, we'll focus on CSS-in-JS as a paradigm, looking at its fundamental principles and key features to understand the underlying technology. Here's what we'll cover next:
- The Origins of CSS-in-JS;
- The basics of defining styles and applying them to elements;
- Granular dead code elimination;
- Trivial variables sharing between styles and JS code;
- Effortless code navigation using editor tooling;
- Static CSS extraction as regular
.css
files for better caching; - Atomic CSS-in-JS outputs atomic styles without the limitations of dedicated frameworks.
- CSS-in-JS criticism despite its versatility.
Origins
It might come as a surprise, but the idea of "styling with JavaScript" is not as novel as many would believe.
In 1996, Netscape implemented JSSS, aka. JavaScript-Based Style Sheets as an alternative to CSS. Netscape even submitted the specification to W3C for standardization. However, it didn't gain enough traction, as "CSS had a much wider industry acceptance". Ultimately, in 2000, Netscape dropped all support for JSSS.
The birth of CSS-in-JS
CSS-in-JS, as it is known today, was coined in 2014. There are two known pioneers, namely Christopher Chedeau from Facebook and Oleg Isonen the author of JSS (not to be confused with Netscape's JSSS from 1996).
At its core, CSS-in-JS is simply an alternative solution to solve the problems of scalable CSS. As a consequence, there is a considerable amount of overlap between CSS-in-JS and the other approaches covered in previous chapters:
- Styles encapsulation is a core feature of any CSS-in-JS library. CSS Modules played an essential role in popularizing CSS class hashing, a technique borrowed by most CSS-in-JS libraries to scope their styles.
- Explicit dependencies enable simplified reasoning about the code because the classes applied in the markup don't reference implicit CSS classes. Instead, there is an explicit reference between the definition of the style and its usage.
- Contextual styling is another core feature of CSS-in-JS libraries, enabling us to define nested rules, media queries, and pseudos within a single style definition, thus preventing code duplication, similar to CSS Preprocessors.
- Avoiding zombie code is mainly a consequence of having explicit dependencies because every component defines or imports its styles. Thus, when we remove the component or don't import it anymore, its styles will also be ignored during bundling.
This overlap could explain why so many developers disregard CSS-in-JS from the start. For instance, their problems might be already solved by CSS Modules or other forms of encapsulation in conjunction with a CSS processor.
Beyond static styling
Besides the core features previously noted, CSS-in-JS solves other problems as well, beyond what CSS Modules or CSS processors can achieve, simply as a consequence of using JavaScript files for styles definition:
- Code navigation features, such as Go to definition or Find all references, are not available with plain CSS. JavaScript-defined styles unlock the power of tools specific to programming languages, significantly improving the development experience.
- Dynamic styling is prevalent in highly interactive applications. State-based styling like toggling styles or component variations and user-defined styles are trivial and straightforward to implement with CSS-in-JS.
- Sharing variables from (S)CSS to JavaScript and vice-versa is technically possible but requires additional boilerplate, and it's error-prone. With CSS-in-JS, we can share not only
constants
orvariables
, but alsotypes
,functions
, or any other JS code. - Type-checking is not possible with regular CSS. If a style is missing or a class name is misspelled, no tool will help us notice the problem. CSS-in-JavaScript unlocks the power of type-checking, covered in detail in Part 8: Type-safe CSS.
In addition, CSS-in-JS unlocked other features that turned out to be trivial to implement, thanks to having all style definitions in JavaScript:
- Theming and especially runtime theme toggling is supported by most CSS-in-JS solutions.
- Critical CSS extraction for Server-Side Rendered pages is implemented in all CSS-in-JS libraries that support SSR.
- Colocated styles within the component file, similar to React Native StyleSheets, Vue.js SFC, or Angular Components, go hand in hand with component-based approaches.
- Lazy loading styles comes out of the box with dynamically loaded components.
Now that we have a basic understanding of the theory behind CSS-in-JS let's look at its typical syntax to define styles and attach them to HTML elements.
The basics
To better understand how CSS-in-JS works, let's imagine a fictional library that supports typical CSS-in-JS features. The first thing we'll have to do is to import the styling API, typically a function called css
or styled
:
import { css } from "css-in-js-library";
Styles definition
Depending on the library's support, the styling function will receive the CSS styles as input, which could be either a JavaScript Object or a Tagged Template string.
const title = css({
fontSize: "2rem",
color: DARK_BLUE,
});
const title = css`
font-size: 2rem;
color: ${DARK_BLUE};
`;
Most libraries prefer the Object syntax because it's more performant, as Tagged Templates require an additional parsing step from string
to object
. However, some libraries like Emotion, Goober, JSS, or Compiled are pretty flexible, supporting both syntaxes.
Styles attachment
Now that we've defined the styles, let's explore how to apply them to HTML elements. There are three different approaches that CSS-in-JS libraries typically support.
Class strings APIThe most intuitive and popular API returns a uniquely generated string
representing the element's CSS class name.
const title = css(/* CSS rules */);
// ➡️ unique string: "1dbj"
The main benefit of this approach is that it resembles the traditional way of styling. But, at the same time, this method is agnostic of the underlying JS framework, working with any JavaScript framework, or using vanilla DOM APIs:
export const Page = () => (
<h1 className={title}>...</h1>
);
document.body.innerHTML = `
<h1 class="${title}">...</h1>
`;
Styled
component APIA second method, popularized by the styled-components library, which also gave its name, takes a different approach. Instead of defining the styles first and applying them to existing HTML elements afterward, a typical styled
API will receive the type of element along with its styles.
const Title = styled("h1")(/* CSS rules */);
// ➡️ new <Title /> component
As a result, it will return a new component with the CSS class already applied. This method works well with component-based approaches, encapsulating the element's definition with its styles and removing the mapping required with standard CSS.
export const Page = () => (
<Title>...</Title>
);
Consequently, the end result will be the same:
<h1 class="1dbj">...</h1>
Libraries that support the Styled
component API include styled-components, Emotion, JSS, Goober, Compiled, and Stitches. It's worth noting that this approach is mostly used with JSX-based frameworks.
css
prop APIThe third method, introduced by Glamor and popularized by Emotion uses a non-standard attribute, typically named css
, to specify the element's styles.
export const Page = () => (
<h1 css={/* CSS rules */}>...</h1>
);
The css
prop API is less prevalent, being supported by fewer libraries, including Emotion, styled-components, Compiled, or Goober. This approach is typically implemented with JSX-based frameworks, where attributes are called props, hence the name of this method.
Styles output
So far, we've only covered a few methods to define and apply the styles to HTML elements. But how does the browser handle the styles written in JavaScript?
As browsers cannot parse CSS defined in JavaScript files, one way to render the styles in the browser is to inject them at runtime. This approach is known as runtime stylesheets, and it's the most popular one among existing libraries.
Additional JavaScript code, called the library runtime, has to be bundled and shipped to the browser for this to work. This code will inject the required styles into the browser and update them accordingly whenever a user event triggers a style change.
The Runtime Stylesheets method is not the only approach. As we'll later see, there are libraries capable of generating Static CSS files that can be referenced like any regular stylesheet. In addition, other solutions can even generate Atomic CSS.
Now that we've covered the basics let's turn our attention to several features that set CSS-in-JS apart from other authoring paradigms.
Dead code elimination
Removing unused code is not limited to CSS-in-JS, as it also works with CSS Modules. However, with CSS-in-JS we get more fine-grained and efficient results:
- with CSS Modules, dead code elimination works at the component or file level;
- but dead code elimination works at the element or style level in CSS-in-JS.
Consider the following implementation with CSS Modules:
import styles from "./footer.module.css";
export function Footer() {
return (
<footer>
<p className={styles.contact}>Get in touch</p>
{/*
✂️ Removed code
<p className={styles.copyright}>Copyright 2021</p>
*/}
</footer>
);
}
.contact {
color: "black";
}
/* 😡 will be bundled, even though it's not used */
.copyright {
color: "grey";
}
All styles defined in the imported CSS Module will be bundled, even the unused ones. There is no trivial way to ensure that a particular class isn't actually used in the code.
As long as the <Footer />
component is imported somewhere in the application, the entire .css
file will also be bundled. However, if the <Footer />
component is not imported anymore, the .css
file will also be removed from the bundle as well.
Next, let's look at the same implementation but using CSS-in-JS instead:
import { css } from "css-in-js-library";
const contact_styles = css({ color: "black" });
// 😌 will not be bundled, as it is not referenced
const copyright_styles = css({ color: "grey" });
export function Footer() {
return (
<footer>
<p className={contact_styles}>Get in touch</p>
{/*
✂️ Removed code
<p className={styles.copyright}>Copyright 2021</p>
*/}
</footer>
);
}
With CSS-in-JS, on the other hand, we have a more direct reference between style definition and its usage. In our example, copyright_styles
is just a JavaScript constant. Since we don't reference it anywhere in the component code, its styles won't be bundled.
In addition, linters can highlight unused identifiers, which we can safely delete. Therefore, the source code is also easier to maintain.
Shared variables
There are many reasons for sharing custom values between CSS and JavaScript code, even in small websites. On the other hand, larger applications, especially those using a design system, will likely require us to share design tokens.
With CSS-in-JS, we don't have to perform explicit conversions or rely on tools to automate the conversions between (S)CSS and JS variables. Since all styles are defined and applied in JavaScript, they can reference any JS identifiers:
import { css } from "css-in-js-library";
import { theme, desaturate } from "./theme.js";
const COLOR = "#f45919";
const heading_styles = css({
// 👍 local defined constant
color: COLOR,
// 👍 imported design token
padding: theme.default_spacing,
// 👍 computed value
background: desaturate(0.2, COLOR),
});
export function Title() {
return <h1 className={heading_styles}>Heading</h1>;
}
It's needless to say that sharing any code between logic, styles, and markup becomes trivial with CSS-in-JS.
Code navigation
As we've all probably experienced by now, we read, debug, and change code more often than writing it. Therefore, navigating effortlessly through code is essential, especially with large or unfamiliar codebases.
Consider a simple task as exploring what CSS rules are defined for a particular CSS class applied to an element:
- Using plain (S)CSS requires us to copy the CSS class, paste it into a search tool and append a dot
.
so we'll find its definition. - With CSS Modules, the process is the same, even though we're explicitly importing the styles.
With CSS-in-JS, all styles are JavaScript constants. The editor will instantly open the style definition using Ctrl + click
(CMD + click
on Mac) or selecting Go to Definition.
The opposite task is also simple. To discover the usages of a particular style (or any other variable, for that matter), we can Right-click
on the identifier and select Find All References from the context menu.
Navigating code easily is not a measurable feature, but once we get used to it and feel its usefulness, it becomes obvious that it dramatically improves the overall development experience.
Static CSS Extraction
There are a lot of complaints about CSS-in-JS regarding its runtime overhead, as it requires additional code to evaluate, inject, and update styles in the browser. Indeed, this overhead varies between 1.1 kB
and 18.2 kB
gzipped and minified.
However, libraries like Astroturf, Linaria, or vanilla-extract implement static CSS file extraction, generating actual .css
files at build time, which are included in our document as any regular CSS Stylesheet.
This technique adds zero runtime cost. Thus, we get all the benefits of CSS-in-JS regarding the development experience and, at the same time, having no runtime cost, similar to regular CSS.
Atomic CSS-in-JS
Some libraries like Fela, Compiled, Stitches, and Stylex took CSS-in-JS to another level. Instead of generating CSS classes containing all defined rules, they focused on generating unique and atomic classes.
Now, the beautiful part about Atomic CSS-in-JS is that we don't have to learn the specific set of class names of a particular Atomic CSS framework. Instead, we write the styles like we usually do with CSS-in-JS.
It's the library's responsibility to generate the required atomic CSS classes.
Statically Atomic CSS-in-JS
Before we conclude, we should also mention solutions like vanilla-extract + Sprinkles, or Style9, which combine Static CSS extraction and Atomic CSS, bringing together the benefits of all three paradigms covered so far:
- the development experience of CSS-in-JS;
- the small and optimized file size of Atomic CSS;
- the trivial caching of Static stylesheets.
Criticism
Despite its versatility and potential, CSS-in-JS received a significant kickback from the community. Most of the reasons are misconceptions, but there are two essential dilemmas that we'll eventually encounter with CSS-in-JS:
- How to choose a CSS-in-JS library? There are more than 50 different solutions, each offering a unique set of features. Undoubtedly, it's not trivial to understand which of them best fits a particular project. Trying out all of them is a daunting task.
- Can we commit to CSS-in-JS in the long term? Choosing a library is only part of the story. But there's no guarantee that a particular popular library today will be maintained 5 or 10 years from now.
If we take a step back, we had similar dilemmas before, with CSS Preprocessors, JavaScript frameworks, state management libraries for React, static type checkers, compile-to-JS languages, etc. But after a period of pioneering, failed experiments, and JavaScript fatigue, the community eventually figured out which solutions worked and which didn't.
Before we move on, let's recap what CSS-in-JS is all about:
- Styles encapsulation, so CSS Modules, Shadow DOM, or other CSS scoping techniques are not required.
- Variables and contextual styles render all CSS preprocessors obsolete.
- Fine-grained dead code elimination successfully removes unused styles at build time out-of-the-box.
- Powerful code navigation support enables features such as Go to definition and Find all references.
- Atomic classes eliminate the need to learn and use Atomic CSS or Utility-first frameworks.
- Static stylesheets remove the runtime cost, providing trivial caching like regular CSS files.
But, there's more. In the final chapter, Part 8: Type-safe CSS, we'll explore the benefits of using TypeScript on top of existing CSS-in-JS solutions, enabling type safety for our CSS code.
References and further reading
- What actually is CSS-in-JS? by Oleg Isonen
- Past, Present, and Future of CSS-in-JS by Max Stoiber
- A thorough analysis of CSS-in-JS by Andrei Pfeiffer
- Atomic CSS-in-JS by Sébastien Lorber