The death of CSS-in-JS and the resurgence of native CSS

A short look back at different styling solutions, my opinions on the ideal styling solution and settling down on a new standard.

Posted in Opinions on

Front-end development is maturing. Petty differences aside, there's a couple of clear trends that are considered objective good, for as much as one can ever truly be objective.

One example, the concept of component based architecture is now engrained firmly in our collective list of best practices. We co-locate all styles, logic related to interaction and templates of our components. Throw away the component and you throw away everything related to it. Clean. Simple. Predictable. We collectively figured out that separation of concerns is a straw man argument.

Styling your application though, that's a touchy subject. A topic that I try to avoid having opinions on publicly. Mostly because I don't have the right answers either. With components and TypeScript I'm convinced those paradigms are objectively better. With CSS, things are more muddy.

The core of the issue

CSS is a deceptively simple language at first sight. But therein lies exactly the problem. It's so easy to get started, that it's almost impossible to understand the truth: Writing maintainable, predictable and high quality stylesheets is challenging, complex work.

I think we need to recognize that at it's core, CSS is a flawed language when it comes to styling modern web applications. I'm hopeful that at some point we'll converge on an idiomatic way of designing, writing and scaling up stylesheets. One that provides the same ergonomics to developers while staying clear of pitfalls such as performance, caching, invalidation or shipping runtime dependencies.

You can try to build a performant, scaleable CSS solution by using methodologies such as OOCSS, SMACSSS or BEM. In theory, these solutions or at the very least parts ot it are sound. In practice, keeping everyone on your team aligned and consistent in applying a methodology look this proves to be a fools errand. These methodologies are not enforced automatically. They fully depend on humans being infalliable. But humans aren't infallible, it's too easy to make mistakes. To make duplicates. To not adhere to a set of rules.

TypeScript will reject your build if you go against the grain. Fix your mistakes, or else. CSS however happily accepts everything you throw at it. No questions asked.

Relying on your own design methodology sounds Sisyphean to me. Keep pushing that boulder up the hill if you like, but the boulder will come crashing down eventually.

The inconvenient truth of vanilla CSS

I believe writing CSS by hand in any medium to large sized project is destined to fail.
No exceptions.

Why? The amount of cognitive energy you need to write maintainable, scaleable, quality stylesheets is staggering. You have to consciously think about namespaces, the cascade, specificity, how to name classes, wether or not you're duplicating styles, design tokens, deleting dead code or the correct order of imports. You have to be consistent in a language that's designed to be flexible.

All of that energy should be focused on something more valuable. Delivering new features, solving bugs, working on performance issues, security. You name it.

Maybe this is the nuance that seems to be missing in most discussions I see online.

It's not about having a great developer experience at the expense of user experience. I don't think anyone wants that. For me, having a great developer experience is fundamental to the succes of a long-term, stable codebase. Without good DX, UX will – at some point – suffer!

Idiomatic styling design methodology

Based on my own experience, here's a list of characteristics I believe an ideal styling solution should provide out of the box:

  • Dead-code elimination to prevent crud
  • Zero runtime dependencies
  • Design tokens to enforce consistency
  • Auto-completion in your IDE
  • Server side rendering
  • Theming support (e.g. light/dark)
  • Framework agnostic, so we can re-use our UI components easily across different tech-stacks.
  • Local scope, as a way to prevent overwriting previous classnames

Prior solutions and libraries

There's been a myriad of tools and developments related to CSS. Almost all of them focus on the same goal. Improving the developer experience of writing CSS. While some of those solutions solve that problem, they do it in different ways and some have implications you might not realize at the start.

Vanilla CSS

There's no denying CSS is maturing. It's not the language it used to be. There's more low-level utilities to build a system that works the way you want it. And that's simultaneously my complaint. You still need to design your own system. It's not an out-of-the-box solution.

Checklist

Satisfies the requirements for:

  • Zero runtime
  • Design tokens (custom properties)
  • Server side rendering
  • Theming support (custom properties)
  • Framework agnostic

Requirements not met:

  • Dead code elimination
  • No autocompletion in your IDE based on your design tokens
  • No support for scoped CSS, although there's a proposal in the works

Summary

All in all, not terrible, but using vanilla CSS requires you to design your own solutions to things like the cascade (e.g. through rigorously enforcing methods like BEM) or eliminating dead code by inspecting your site through the Chrome Web Inspector.

CSS-in-JS / CSS-in-TS

Many of the requirements listed above are easily automated through a language like JavaScript. It's no surprise that CSS-in-JS solutions have been so popular the last couple of years. They offer all of the convenient tooling of JavaScript in a flexible, easy-to-use API that works well with your IDE.

While these solutions provide a great DX, in some cases the UX can suffer. Performance issues are an obvious example. Every style change requires calculations on the clients CPU, which is a highly inefficient way to update styles. Because the CSS is put in the head of your page, it's not cached by your browser, leading to wasted bandwidth.

This is a battle between efficiency and effectiveness.

It provides ergonomic, effective ways of authoring components. However, updates depending on runtime execution is not efficient at all. To combat the inefficiency, some solutions such as vanilla-extract and linaria come full circle, back to extracting styles to static CSS files.

Requirements

Satisfies the requirements for:

  • Design tokens (theme configurations)
  • Theming support (custom properties)
  • Dead code elimination since all styles are co-located with your components
  • Defining your theme in TypeScript offers perfect autocompletion in your IDE
  • Locally scoped styles by default

Doesn't really solve:

  • Zero runtime (linaria, vanilla-extract) at the cost of complexer build configurations
  • Server side rendering. Modern frameworks such as Remix and Next.js actively discourage CSS-in-JS, because they don't work well with React Server Components and have unsolved performance issues.
  • Framework agnostic. You might need a runtime dependency, or use a specific library for your framework. Styles are not as portable as you'd like them to be.

Summary

Provides great ergonomics, at the cost of hard to solve performance and portability issues. The solutions that circumvent the performance issues are tighyly coupled to your project configuration.

Seeing how Remix, Next 13 and Svelte all seem to move away from CSS-in-JS in favor of more native CSS solutions, I expect this to fall out of grace quickly. I do hope some of the ergonomics make their way in new solutions.


Tailwind

Tailwind isn't exactly the new kid on the block anymore. Heck, it might even be the most trendy kid on the block by now.

It's very popular, because the core concept and utility-first fundamentals directly support all best practices we're looking for. You're shipping real CSS files that can be cached by your browser. CSS files are relatively small and stripped away from unused selectors. It has theme support, dark mode support, and even sets up a set of sane defaults for your application for you. You can tweak the default config, but you don't have to.

However, I've been rejecting tailwind for the longest time. At first, because I was repulsed by the amount of classnames you need to write in your templates. Then I saw people using things like twin.macro. This strengthened my conviction that tailwind is great for beginners, but lacks the flexibility and power that a more demanding web application needs. It is an abstraction after all. Third, I don't want to remember all abstractions in my head.

I'll admit, having tens of classes on your elements is still appalling. However, being able to copy-paste classnames around in elements is pretty great. And honestly, when you extract those UI components to their own files, it's not as in your face anymore as the docs might lead you to believe. It's a non-issue really.

While it's not as great as having a fully typed style system such as in Stitches, the editor integration is solved through the Tailwind CSS IntelliSense plugin for VScode. It provides some good feed-forward on which classes are available to use, and shows previews of the actual style rules! Pretty good!

Tailwind version 3.2 introduces support for ARIA and data attribute variants. This makes tailwind so much more powerful. You can use these selectors to conditionally style your components based on the state of your components. This opens up the doors for conditional, static stylinh compatible with MPA applications.

I'm now convinced that tailwind is mature enough out of the box to be used on even the most demanding applications.

Requirements

Satisfies the requirements for:

  • Design tokens
  • Theming support
  • Dead code elimination
  • Zero runtime
  • Server side rendering
  • Framework agnostic

Sort of solves:

  • It's not fully typed, but the IDE offers enough feed-forward to make authoring components a breeze.

Summary

Tailwind provides great defaults and an out-of-the-box scaleable design system that's easy to adopt across your whole organisation, no matter which tech stack you use. To boot, the utility first approach prevents common problems with CSS while removing the overhead of working with methodologies such as BEM.

The fact that tailwind work flawlessly with newer framework such as Remix and the new Next.js is further proof that they're on the right track.

Closing off

Can 2023 be the year where we finally ditch our CSS-in-JS solutions we so dearly love? Let's bite the bullet together and give Tailwind a try. Maybe once we all stop being upset about the aesthethics of tailwinds classnames, we can push the web forward by delivering performant, accessible front-ends while still enjoying most of the ergonomics we're now used to and depend on.