CSS in 2024 is amazing.
Cross-browser support for nesting, :has(), container queries, and more1
Many frameworks and compilers to help optimize CSS loading performance
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?
Stylesheets should load as fast as possible (small file sizes)
Stylesheets should not re-download unless changed (proper caching headers)
The page content should have minimal or no layout shift
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?
Prune unused styles, minify, and compress CSS for smaller file sizes
Generate hashed file names to enable safe, immutable caching2
Bundle CSS files together to make fewer network requests
Prevent naming collisions to avoid visual regressions
What about to help us write more maintainable, enjoyable CSS?
Easy to delete styles when deleting corresponding UI code
Easy to adhere to a design system or set of themes
Editor feedback with TypeScript support, autocompletion, and linting
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.
This example I created uses many of the latest CSS features supported cross browser without any build step. You might not need Sass or Less anymore!
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:
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
Automatically generate unique file names, allowing frameworks to set caching headers like
immutable
signaling to browsers the content will never changeSpecify 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
andautoprefixer
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:
VSCode integration for autocompletion, linting, syntax highlighting, and more
Prettier integration for automatic sorting of class names
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.
Here’s another version of the first example, but written in Tailwind.
StyleX
There are two issues with most CSS-in-JS libraries:
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.
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 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.
Here’s another version of the first example, but written in StyleX. This was my first time using it. 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? Respond and let me know what you think.
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.
Useful article! Thank you!
CSS modules x CSS variables in Next.js have some issues
- https://github.com/vercel/next.js/discussions/19055
- https://github.com/vercel/next.js/issues/13092