Andrei Pfeiffer logo
Back to Articles

The evolution of scalable CSSEpilogue

CSS
10 min read

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.

  1. Introduction
  2. Part 1: CSS scalability issues
  3. Part 2: Good practices
  4. Part 3: CSS Processors
  5. Part 4: Methodologies and Semantics
  6. Part 5: Styles Encapsulation
  7. Part 6: Atomic CSS
  8. Part 7: CSS-in-JS
  9. Part 8: Type-safe CSS
  10. Epilogue

During this series, we've covered almost two decades of innovation regarding CSS authoring tools and techniques. Since we're slowly approaching the present day and also the end of this chronicle, it's time to take a step back, reflect on the knowledge we've gained, and draw some conclusions.

Here's a summary of what we'll cover in this epilogue:

Paradigms

To simplify things, we have three different paradigms we can choose from to address the scalabilities issues of CSS. Of course, we'll usually choose one of them, but it's essential to remember that they are not mutually exclusive.

In practice, no matter which approach we choose as our primary paradigm, we'll probably use the others as well in particular situations. For instance:

  • With Semantic CSS, we'll most probably use a fair amount of atomic utility classes like all Semantic CSS frameworks include.
  • Similarly, with Atomic CSS, we'll have to use semantic classes to define styles for CSS animations, descendant selectors, or pseudo-classes.
  • In dynamic applications, where the user defines styles for an element's position, size, or color, we'll have to update those styles from JavaScript.
  • Even with CSS in JS, we'll have to turn to regular stylesheets whenever we need to override 3rd party CSS code.

So, let's recap and synthesize what these paradigms are all about.

Semantic CSS

It's safe to say that Semantic CSS is the de facto standard within the developers' community and the traditional paradigm of CSS styling.

Semantic CSS encourages using class names that describe the meaning of the HTML element they represent, not their implementation. Such class names include .card, .button, or .video-player and contain all the required styles for that particular element. This approach creates a clear separation between content and style.

However, some things are worth mentioning about Semantic CSS when we think about CSS maintainability and scalability:

  • The CSS code will continually grow as we add new features, which could potentially lead to large .css files.

  • Styles encapsulation using CSS Modules or Shadow DOM is essential for scaling and maintaining Semantic CSS. Without them, we'll have to address the global namespace problem using CSS methodologies and architectures, which, as we saw, are not trivial to scale or maintain in the long term.

  • We'll probably use CSS Processors for nesting and contextual styles, which are key features, especially when using media queries in Responsive Web Design.


Even though the large majority of classes have semantic names, we'll also be using a fair amount of non-semantic and reusable utility classes for quick and punctual style overrides.

Atomic CSS

As an alternative, Atomic CSS takes a totally different approach, fully embracing non-semantic and reusable utility classes such as .text-bold or .display-flex. These classes explicitly describe their implementation and contain a single CSS rule.

  • The CSS code will not grow indefinitely, and the total CSS size will be considerably smaller than Semantic CSS.

  • Stylesheets can have longer cache time, as we'll mostly use existing CSS definitions without adding new ones.

  • There will be an increase in markup size, as we'll need to attach more classes on HTML elements.

  • We don't need other tools for scaling CSS since the fundamental principles of Atomic styles address most CSS scalability issues.

  • We'll have to learn a framework unless we want to implement our own.


Some edge-cases might require traditional semantic classes for CSS animations, descendant or compound selectors, or certain pseudo-classes such as ::before or ::after.

CSS-in-JS

When the styles depend tightly on behavior, separating them will only impede development and maintenance as well. That's why CSS in JS is a perfect fit for highly interactive applications, where most styles are dynamically computed based on user actions.

  • Any CSS-in-JS library provides support for contextual styles and styles encapsulation, therefore we don't need additional tools like CSS Processors or CSS Modules.

  • We have the option to colocate the styles with their respective component file. Doing so removes one layer of indirection and improves component maintenance.

  • We can easily share variables between styles and logic, which could play an important role in dynamic applications or design systems.


But nowadays, CSS-in-JS use cases are not limited to highly dynamic applications.

  • Libraries such as vanilla-extract or Linaria support static .css files extraction, providing the development experience of CSS in JS besides the caching advantages of traditional stylesheets.

  • Other approaches, such as vanilla-extract + Sprinkles and style9, output Atomic styles on top of static CSS extraction. This eclectic approach provides the benefits of all 3 paradigms simultaneously.


Styles for text color blue and font weight bold defined in 3 different paradigms: first, Semantic CSS which uses CSS stylesheets for styles definitions; second, Atomic CSS where styles are specified in the HTML markup by applying existing CSS classes; and third, CSS in JS where styles are defined in JavaScript files
Each CSS paradigm uses a different language for styles authoring

An interesting observation is that each CSS paradigm employs one of the 3 languages used for front-end web development:

  • With Semantic CSS, we use regular CSS stylesheets to define the styles. We also touch HTML markup to apply the classes, but the actual CSS rules are written in CSS files.

  • With Atomic CSS, we apply existing CSS classes directly to HTML elements. The atomic classes might be defined in CSS stylesheets, but from the authoring perspective, we'll actually touch only the markup files during development.

  • With CSS in JS, as the name implies, all style definitions are moved to JavaScript files. Even if we use static CSS extraction or Atomic CSS-in-JS, the actual style definition takes place in JS/JSX files.

Static vs. Runtime Stylesheets

So far, we've looked at CSS from the authoring perspective. Another angle we can explore is analyzing the output and loading of the stylesheets.

Static Stylesheets

Whether we're using Semantic or Atomic CSS, CSS processors, CSS Modules, or CSS-in-JS with static extraction, what we get in the end is one or more static .css files that we'll include in the <head> of our document:

<link rel="stylesheet" href="styles.css" />

Every single web developer is familiar with this method to load CSS, as this is the standard approach. However, there are some considerations to keep in mind:

  • By default, any .css file included in the document's head element is a rendering blocking resource. Depending on the file size, compression ratio, network conditions, server response time, latency, user bandwidth, and CPU power, loading a .css file could degrade the Speed Index or the page.

    Extracting critical CSS aka. above-the-fold CSS is a solution, but not a trivial one to implement, especially with dynamic websites and applications.

  • CSS files are easily cached by browsers, preventing unnecessary requests for the same file. However, this brings no benefit if any style change invalidates the entire CSS file. Frequent changes defeat the purpose of caching.

  • Content and styling are strictly separated, meaning we can update content without invalidating the CSS cache. Therefore static stylesheets are beneficial during Server-Side Rendering.

  • On the other hand, the initially loaded .css file must contain all the dynamic styles required by user events unless we lazy-load them at runtime.

Runtime Stylesheets

Whenever we have to deal with user interaction, we'll probably need to update some styles as well. Some basic examples might be adding, removing, or toggling CSS classes from JavaScript event handlers.

Dynamic styles, popularized by CSS in JS libraries, will be bundled in JavaScript files during the build. When the JavaScript bundle reaches the browser, the CSS in JS runtime will inject the styles directly into the CSSOM.

  • Lazy-loading the styles for a particular component becomes trivial, since the styles are bundled with their respective component.

  • Dead code elimination is provided out of the box, as unreferenced style definitions will not end up in the JavaScript bundle.

  • Dynamic stylesheets typically require more code to be shipped to the browser. Extra runtime code is necessary to handle dynamic styles, varying between 1.1 kB and 18.2 kB. In addition, the critically extracted styles will be shipped twice during Server-Side Rendering: once for the initial render and second when bundled in JS files during the build.

  • First paint metrics might be faster, even though we're shipping more bytes. Since regular stylesheets block the rendering of the page, removing them altogether should speed up the loading.

Type safety

Traditional web technologies like HTML, CSS, and JS typically lack any kind of type safety, leading to cumbersome maintenance and fear of touching existing code.

Using static type checkers like TypeScript or Flow provides type safety for the logical part of the code. With JSX-based syntax, type checkers also provide type safety for the component's markup. But adding static type checkers on top of CSS in JS, we can finally achieve type safety for styles as well.

  • Code navigation tools embedded in most editors and IDEs are beneficial to go to styles definition quickly or find their usage within a large code base.

  • Refactoring tools aid us when renaming, extracting, and moving code around. Code refactoring is essential during long-term maintenance.

  • Instant type checking highlights any unused definitions, misspelled identifiers, or incorrect function calls.

  • Typed interfaces for UI components enforce solid contracts for the consumers. Passing invalid input becomes very unlikely.

  • Static types are typically self-documented, being beneficial for any developer reading the code.

Closing thoughts

In the end, it's just a matter of scalability. For example, we could compare code maintenance with traveling from one place to another, considering that the effort scales proportionally with the distance. Additionally, the more people we travel with, the more difficult it is to coordinate them.

๐Ÿšถ Walking

First, let's consider walking. We don't need any rules or regulations as the traveling speed is relatively low. To decrease traveling time, we can choose to run, or even take a bike.

However, none of these approaches will get us very far. In addition, each person traveling with us will have to put in the same amount of effort. In the context of CSS, the same limitations apply to standard Semantic CSS and the good practices necessary for scaling.

๐Ÿš™ Driving

If we want to travel farther, we'll probably take a car. This method scales a bit better, as the effort to travel long distances is considerably lower than walking. We can also take additional people with us, without additional costs on their side.

But driving on public roads requires specific rules and regulations: we must have a driving license, learn the priority signs, use signaling lights, etc. In the context of scalable CSS, this translates to using CSS methodologies for individual components and high-level architecture.

We might even take the highways at some point, as they are specifically designed to enable higher speeds. Using styles encapsulation, learning an Atomic CSS framework, or choosing a CSS-in-JS solution, provide similar benefits, as they remove a considerable part of the manual effort required to manage the code.

โœˆ๏ธ Flying

Now, what if we have to travel even farther or get to a different continent over the seas? Even driving exclusively on highways could take days to reach our destination. On the other hand, taking a plane allows us to reach faraway places quickly and with minimal effort.

However, getting on a plane is not straightforward: we need a passport, get through security checks, and follow strict regulations during the flight. Both the airport and the flight company have to enforce stringent security measures.

Type-safe CSS using a static type checker provide similar mechanics. They enforce safety checks, which allows us to move faster and farther, without a proportional amount of effort.


Figuring out how far we want to go will help us make educated decisions regarding what tools best suits our journey.


Acknowledgments

Before we close this chronicle, there is a handful of people I would like to pass my gratitude to.

  • First of all, a huge shoutout to Adi Fรขciu @adrianfaciu, Alex Moldovan @alexnmoldovan, and Andrei Antal @andrei_antal for their thorough review of this chronicle. Their valuable feedback considerably shaped the content to reach this final form.
  • Last but not least, I would also like to thank Oleg Isonen @oleg008 for his constructive criticism of my analysis of CSS in JS, which helped me better understand the underlying technology.

Scroll to top