A.

Blog

Authoring CSS in modern JavaScript apps

Aug 31, 2024

In the past couple years, there’s been a decent amount of development on how to author CSS in JavaScript web applications. New libraries are popping up all the time, but nothing has necessarily taken over as the way to style JavaScript apps. In this post, I’ll go over what’s out there and some of the pros and cons of each solution.

Plain CSS

First up is just plain old CSS. There’s been so much advancement in the world of CSS, that you really don’t need much else on top of it. Things like variables and selector nesting have made the it much easier to architect and write great CSS without the need for separate tooling like a preprocessor.

So how does using just CSS look like these days?

/* card.css */

:root {
--color-background: #fff;
--color-border: #e9ecef;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--radius: 12px;
}

.card {
max-width: 300px;
font-family: var(--font);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 24px;
transition: box-shadow 0.4s ease;

&:hover {
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
}

img {
display: block;
width: 100%;
border-radius: var(--radius);
}
}

// card.tsx

import "./card.css";

export function Card(props) {
const { src, title, children } = props;
return (
<div className="card">
<img src={src} />
<h2>{title}</h2>
{children}
</div>
);
}

There’s a lot to like with this approach. It’s simple (everyone should know how to write proper CSS), performant, and easy to understand. On the other hand, because it’s just CSS, you do lose out on the extra functionality that you would get with a preprocessor or CSS-in-JS. For example, CSS-in-JS libraries make it really easy to augment your CSS with JS (duh!). Sometimes this can really come in handy when there isn’t really a native way to do something (e.g., extra color functions) or if you need to programmatically output some CSS.

Another thing you lose out on is that all of your styles are in the global scope. This isn’t a new problem, but it’s not something that plain old CSS solves for you. And because everything is in the global scope, it can sometimes be hard to keep track of styles without some strict naming conventions in place.

Overall, using plain CSS is still a great choice for smaller applications. But if you’re looking to build something larger, then I’d personally opt for a different approach.

Preprocessors

Preprocessors like SASS, LESS, and Stylus have fallen out of the vogue for a bit now. A lot of their more popular functionality has made it into CSS (e.g., variables and selector nesting). That said, CSS hasn’t incorporated all of preprocessor behavior so there can often still be some valuable bits to tap into. For instance, I personally find it easier to structure and organize large projects with the extensions that preprocessors tack on top of @import.

Let’s take a look at how using SCSS (SASS) looks like:

// card.scss

$color-background: #fff;
$color-border: #e9ecef;
$font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$radius: 12px;

.card {
max-width: 300px;
font-family: $font;
background: $color-background;
border: 1px solid $color-border;
border-radius: $radius;
padding: 24px;
transition: box-shadow 0.4s ease;

&:hover {
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
}

img {
display: block;
width: 100%;
border-radius: $radius;
}
}

// card.tsx

import "./card.scss";

export function Card(props) {
const { src, title, children } = props;
return (
<div className="card">
<img src={src} />
<h2>{title}</h2>
{children}
</div>
);
}

Quite similar looking to CSS with similar drawbacks like occupying the global scope.

Given how much CSS has grown since the inception of preprocessors and the movement towards component driven applications, it’s hard to make the case for having a dependency on a preprocessor today. Tools like PostCSS and Lightning CSS have also filled in for other functionality that preprocessors were used for like vendor prefixing and polyfills.

CSS modules

CSS modules solves the global scope issue and helps with clarifying style dependencies (e.g., specificity hell 👿). This is a huge boon, especially when working in large projects where selector collisions are more likely. If you’re not familiar with CSS modules, I highly recommend checking out the docs. Essentially, what it does is that it allows you to write what is effectively CSS and then your bundler will produce a locally scoped set of class names where the styles are used.

So your selector of .foobar would actually surface with some sort of hashing (typically of the file path) that would make it unique such as .foobar_XyZabC . Sometimes you might even see the original class name completely obscured.

Using CSS modules looks something like this:

/* card.module.css */

:root {
--color-background: #fff;
--color-border: #e9ecef;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--radius: 12px;
}

.card {
max-width: 300px;
font-family: var(--font);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 24px;
transition: box-shadow 0.4s ease;

&:hover {
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
}

img {
display: block;
width: 100%;
border-radius: var(--radius);
}
}
// card.tsx

import styles from "card.module.css";

export function Card(props) {
const { src, title, children } = props;
return (
<div className={styles.card}>
<img src={src} />
<h2>{title}</h2>
{children}
</div>
);
}

Again, similar syntax to plain CSS with the exception of how class names are specified in JavaScript. Rather than a string literal, CSS modules requires you to import the styles as a named import which allows you to access the class names from the imported object.

CSS modules are a great choice for small to large sized projects. It’s lightweight to implement and has all the simplicity and ease of use as CSS, but solves the foundational issue of global scopes.

CSS-in-JS

Last but not least is CSS-in-JS. Writing your CSS in JavaScript sounded really weird to me at first, but there are quite a few benefits like type safety and theme definition support which can be really great. There’s actually a decent amount of variance between CSS-in-JS libraries despite them all being lumped under the same umbrella term. There is run-time vs build-time and colocated source vs separated source.

Run-time CSS-in-JS libraries work by injecting styles for a given component when it is rendered. You can imagine that this can (and does) lead to performance issues which is largely why runtime CSS-in-JS solutions have become more and more unpopular these days.

Run-time CSS-in-JS libraries include styled-components, Emotion, and JSS. Oftentimes, run-time libraries have their source colocated with usage.

Here’s what using styled-components looks like:

// card.tsx

import styled from "styled-components";

const vars = {
color: {
background: "#fff",
border: "#e9ecef",
},
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
radius: "12px",
};

const StyledCard = styled.div`
max-width: 300px;
font-family: ${vars.font};
background: ${vars.color.background};
border: 1px solid ${vars.color.border};
border-radius: ${vars.radius};
padding: 24px;
transition: box-shadow 0.4s ease;

&:hover {
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
}

img {
display: block;
width: 100%;
border-radius: ${vars.radius};
}
`;

export function Card(props) {
const { src, title, children } = props;
return (
<StyledCard>
<img src={src} />
<h2>{title}</h2>
{children}
</StyledCard>
);
}

Clearly a vast difference from what we’ve seen so far. One of the main differences (and primary benefits) is the colocation of styles with our component. CSS-in-JS also adopts the local scoping seen in CSS modules so you can rest assured that the styles you’re authoring will only affect this specific component.

Build-time (also referred to as “zero run-time”) CSS-in-JS libraries work a little differently. You still define your CSS in JavaScript, but the resulting styles are extracted at build time into static CSS files. This overcomes the performance issues of the run-time libraries at the trade off of dynamic styling capabilities which can be quite useful for some theme-oriented cases. These types of libraries require some sort of bundler integration usually through a plugin.

Build-time CSS-in-JS libraries include vanilla-extract, Panda CSS, and StyleX. Generally, build-time libraries have their source separated from usage.

Here’s what using vanilla-extract looks like:

// card.css.ts

import { createTheme, globalStyle, style } from "@vanilla-extract/css";

export const [theme, vars] = createTheme({
color: {
background: "#fff",
border: "#e9ecef",
},
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
radius: "12px",
});

export const card = style({
maxWidth: 300,
fontFamily: vars.font,
background: vars.color.background,
border: `1px solid ${vars.color.border}`,
borderRadius: vars.radius,
padding: 24,
transition: "box-shadow 0.4s ease",

":hover": {
boxShadow: "0 8px 12px rgba(0, 0, 0, 0.1)",
},
});

globalStyle(`${card} img`, {
display: "block",
width: "100%",
borderRadius: vars.radius,
});
// card.tsx

import * as styles from "./card.css";

export function Card(props) {
const { src, title, children } = props;
return (
<div className={`${styles.theme} ${styles.card}`}>
<img src={src} />
<h2>{title}</h2>
{children}
</div>
);
}

In this case, it’s a bit closer to traditional CSS where you write your styles and then reference them via a class name. However, some build time CSS-in-JS libraries (like StyleX) also allow you to colocate your styles. Similar to the run-time variant, build-time libraries also aim to adopt local scoping.

For large, complex projects, it makes a lot of sense to adopt a build-time CSS-in-JS solution. There is so much utility built into to these libraries like themeing, variants (i.e., easy props to class name mapping), and type safety across the stack. And best of all, you can leverage JavaScript to pretty much programmatically do anything you need to do. Need to reference a value used your styling? No problem, just export it and import it where its needed. Have a bunch of repetitive styles that you need to augment with arguments? Easy-peasy, just write a function to do it.


You’ll notice that I didn’t talk about any of the utility-first libraries like Tailwind CSS or Tachyons. That’s because I lump them into the plain CSS camp. The most similar paradigm is probably Bootstrap, Foundation, and other more “UI framework” solutions.

If I had to spin up a new JavaScript project today, here’s what I would do: for small things like prototypes and low stakes side projects, I would use a mix of plain CSS and CSS modules. It’s a great balance between getting things done fast and getting things done right. For anything larger than that, I would use CSS-in-JS specifically vanilla-extract. I like vanilla-extract’s more unopinionated nature compared to Panda CSS and it has many more features compared to StyleX.

CSS is probably one of my favorite things about building for the web so I hope that this has been informative. You can find the examples I went over above on GitHub.