How I'm Writing CSS in 2024

January 7, 2024 (3mo ago)

CSS in 2024 is amazing.

This post will be a collection of my notes and thoughts about the CSS ecosystem and the tools I'm currently using.

Design Constraints

User Experience

What does a great experience look like loading stylesheets when visiting a website?

  1. Stylesheets should load as fast as possible (small file sizes)
  2. Stylesheets should not re-download unless changed (proper caching headers)
  3. The page content should have minimal or no layout shift
  4. Fonts should load as fast as possible and minimize layout shift

Developer Experience

Our tools must help us create better user experiences. The developer experience, while important, can't come before the user experience.

How can the DX of the styling tools we use help us create a better UX?

  1. Prune unused styles, minify, and compress CSS for smaller file sizes
  2. Generate hashed file names to enable safe, immutable caching²
  3. Bundle CSS files together to make fewer network requests
  4. Prevent naming collisions to avoid visual regressions

What about to help us write more maintainable, enjoyable CSS?

  1. Easy to delete styles when deleting corresponding UI code
  2. Easy to adhere to a design system or set of themes
  3. Editor feedback with TypeScript support, autocompletion, and linting
  4. Receive tooling feedback in-editor to prevent errors (type checking, linting)

CSS in 2024

It's never been easier to write great styles without any additional tooling.

The example below uses many of the latest CSS features supported cross browser without any build step. You might not need Sass or Less anymore!

:root {
  --main-bg-color: #f3f4f6;
  --title-color: #262626;
  --text-color: #525252;
  --font-family: "Arial", sans-serif;
}

body {
  margin: 0;
  padding: 0;
  background-color: var(--main-bg-color);
  font-family: var(--font-family);
}

.blog-header,
.blog-footer {
  text-align: center;
  padding: 1rem;
  background-color: var(--title-color);
  color: white;
}

.blog-post {
  container-type: inline-size;
  margin: 1rem;
  padding: 1rem;
  background-color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

  & .post-title {
    color: var(--title-color);
    margin: 0 0 1rem 0;
    text-wrap: balance;
    font-size: 1em;
  }

  & .post-content {
    color: var(--text-color);
  }
}

@container (min-inline-size: 500px) {
  .blog-post {
    padding: 1.5rem;

    & .post-title {
      font-size: 1.25em;
    }
  }
}

Does that mean the tooling is no longer necessary? For some people, yes.

Build Steps

To meet the design constraints above, you'll likely need a build step.

It's unlikely all your users are on the latest browser versions. But more importantly, there will always be newer syntax that isn't yet supported cross-browser you will want to use.

You can manually write @supports rules to check for browser support, but that's only solving some of the problems. Rather than leaving the CSS optimization to humans, why not let the machines handle it?

Compilation

Compilers make the following workflow easy:

  1. Automatically remove any unused styles, bundle files together to make fewer network requests, add vendor prefixes, and minify the output by removing white spaces and comments
  2. Automatically generate unique file names, allowing frameworks to set caching headers like immutable signaling to browsers the content will never change
  3. Specify target browsers (browserslist) and have syntax lowering to compile modern CSS features to work with those browsers

Streaming CSS

You visit Google to book a flight. It can't precompute your intent, so you're given a search bar for the initial UI. You search "Flight SFO to NYC" and the server streams in a flights widget to select dates.

There is no way Google could have included every possible widget ahead of time. Currency conversions, timers, live sports scores, you name it. The UI and styles for these widgets need to be dynamically streamed in.

React (and Next.js) now support this pattern with streaming SSR and CSS. In the React model, you define your UI as components which have dependencies on styles. How can we safely stream in styles for the a widget without affecting anything on the page?

Styles need to be scoped, or atomic, so that if they load earlier than the DOM content they're intended to style, they don't alter the style of elements already on the page.

For example, CSS Modules have styling rules scoped to the component that imports it. Tailwind uses atomic utility classes, which are compiled into a single stylesheet that's loaded before any classes are used. StyleX generates atomic classes as well. Global styles don't work well with streaming unless loaded at the beginning of the stream.

My Recommendations

CSS Modules

CSS Modules are a small but impactful enhancement on top of vanilla CSS.

They achieve our desired UX constraints and most (but not all) of our DX constraints. They're available in almost every modern bundler and framework. You can copy / paste existing CSS selectors and they'll work in a CSS Module without any changes.

They can't generate atomic styles. They don't support using many themes (just CSS variables). And because the styling code lives outside your TypeScript files, you don't get type safety and autocompletion. But those constraints might be fine for you.

💡

Lightning CSS, which supports CSS Modules, is used by Vite, and soon by Tailwind and Next.js. Tools like postcss and autoprefixer are being replaced by faster, all-in-one Rust toolchains.

Tailwind CSS

Tailwind uses a compiler to generate only the classes used. So while the utility CSS framework contains many possible class names, only the classes used (e.g. "font-bold text-2xl") will be included in the single, compiled CSS file.

Assuming you only write Tailwind code, your bundle will never be larger than the total set of used Tailwind classes. It's extremely unlikely you would use them all. This means you have a fixed upper bound on the size of the generated CSS file, which is then minified, compressed, and cached for the best performance.

You don't have to only write Tailwind styles. Tailwind classes are just utilities for normal CSS that adhere to a design system. You can mix and match Tailwind with CSS Modules, for example.

Tailwind doesn't come without tradeoffs. There's a bucket of tools that pair with it:

The most controversial part about Tailwind is the syntax. It's both loved and hated. I didn't appreciate Tailwind until I built something with it, so I'd recommend trying that if your initial reaction is adverse.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Minimal Blog</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body class="bg-gray-100 font-sans">
    <header class="text-center text-3xl font-bold py-8 bg-neutral-800 text-white">
      <h1>Minimal Blog</h1>
    </header>
    <main class="w-full px-4">
      <article class="my-4 p-4 bg-white shadow-md">
        <h2 class="text-neutral-800 mb-4 font-bold">The Art of CSS</h2>
        <p class="text-neutral-600 leading-5">
          Discovering the latest features in CSS can transform the way we design
          and interact with web content.
        </p>
      </article>
      <article class="my-4 p-4 bg-white shadow-md">
        <h2 class="text-neutral-800 mb-4 font-bold">Exploring Web Design</h2>
        <p class="text-neutral-600 leading-5">
          A journey through the evolution of web design, from static pages to
          dynamic, responsive experiences.
        </p>
      </article>
    </main>
    <footer class="text-center py-8 bg-neutral-800 text-white">
      <p>&copy; 2023 Minimal Blog</p>
    </footer>
  </body>
</html>

StyleX

There are two issues with most CSS-in-JS libraries:

  1. Performance: Components must convert the styles written in JS into CSS to be inserted into the document when rendering. This can have a significant cost and is why libraries are moving to “zero runtime” libraries like StyleX.
  2. Compatibility: Many existing CSS-in-JS libraries have added support for React's streaming server-rendering, but are still incompatible with other performance optimizations like moving parts of your application to React Server Components.

To solve these issues, “zero runtime” CSS-in-JS libraries like Vanilla Extract, Panda, and others have been created.

StyleX is the latest CSS-in-JS library, which solves these problems and more. I'd recommend reading through “Thinking in StyleX” if you want to dig in.

This example was my first time using StyleX. While it's still new to open-source (and the ecosystem reflects that), it's not a new library. It powers all the Meta sites: Facebook, Instagram, WhatsApp, and Threads.

You still have to name things though 🫠 Enter buttonWrapperContainer.

Conclusion

Is CSS... fun for me now? I guess so. I'm excited to see what the next few years bring.

Would you have picked something different? Did I miss anything? Lmk.


¹: More: linear() easing, subgrid, dynamic viewport units, color spaces, and @layer.

²: Since the file names are guaranteed to be unique, you can set the immutable caching header to tell browsers the content will never change. This allows browsers to cache the file forever, which is great for performance.