Skip to main content

You (probably) don't need CSS-in-JS

00:07:09:06

When we first tried CSS-in-JS libraries like Styled Components and Emotion, we appreciated how they allowed us to pass values or state directly into component styles. This approach aligned well with React's concept of the UI being a function of state. While this was an advancement over traditional methods of styling React components, it still had its drawbacks.

To illustrate, we'll break down examples using two main types of dynamic styles encountered in React components:

  1. Values: Single values for a CSS property, like a color, delay, or position.
  2. States: Styles associated with different component states, such as a primary button variant or a loading state.

Where We Are Today

For comparison, we'll use SCSS (with BEM syntax) and Styled Components to show how styling is typically approached in React. We won't cover CSS-in-JS libraries that use JavaScript objects to write CSS. There are already good solutions out there (like Vanilla Extract) for those who prefer type checking and JavaScript-centric styles. Our solution is aimed at those who enjoy writing CSS as CSS but want to better respond to component reactivity and state.

If you're familiar with the problem, skip to the solution.

Values

Using vanilla CSS or pre-processed CSS like LESS or SCSS, the traditional method of passing a value to your styles was to use inline styles. For instance, a button component with a color prop would look like this:

jsx
function Button({ color, children }) {
  return (
    <button className="button" style={{ backgroundColor: color }}>
      {children}
    </button>
  );
}

This approach brings issues like higher specificity, making styles harder to override, and separating styles from the rest of our button styles.

CSS-in-JS (with Styled Components or Emotion) solved this by allowing dynamic values to be passed as props:

jsx
// We can pass the `color` value into the styled component as a prop
function Button({ color, children }) {
  return <StyledButton color={color}>{children}</StyledButton>;
}

// The syntax is a little funky, but now in the styled component's styles
// we can use its props as a function
const StyledButton = styled.button`
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: ${props => props.color};
`;

States

Traditionally, we used CSS classes and string concatenation. This felt messy but worked well, especially with naming conventions like BEM and pre-processors. For example, handling button sizes and a primary variant might look like this:

jsx
function Button({ color, size, primary, children }) {
  return (
    <button
      className={['button', `button--${size}`, primary ? 'button--primary' : null]
        .filter(Boolean)
        .join(' ')}
      style={{ backgroundColor: color }}
    >
      {children}
    </button>
  );
}
scss
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  &--primary {
    background-color: $primary-color;
  }

  &--small {
    height: 30px;
  }

  &--medium {
    height: 40px;
  }

  &--large {
    height: 60px;
  }
}

The SCSS is looking nice and clean. I've always liked the pattern of using nesting to concatenate elements and modifiers in SCSS using the BEM syntax.

Our JSX, however, isn't faring so well. That string concatenation on the className in the is a mess. The size property isn't too bad, because we're appending the value directly onto the class. The primary variant though... yuck. Not to mention the wacky filter(Boolean) in there to prevent a double space in the class list for non-primary buttons. There are better ways of handling this, for example the classnames package on NPM. But they only make the problem marginally more bearable.

Unlike dynamic values, Styled Components is still a bit cumbersome in dealing with states

jsx
function Button({ color, size, primary, children }) {
  return (
    <StyledButton color={color}>{children}</StyledButton>
  }
);

const StyledButton = styled.button`
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  ${props => props.primary && css`
    background-color: $primary-color;
  `}

  ${props => props.size === 'small' && css`
    height: 30px;
  `}

  ${props => props.size === 'medium' && css`
    height: 40px;
  `}

  ${props => props.size === 'large' && css`
    height: 60px;
  `}
`;

While not terrible, the repeated functions to grab props get repetitive and make reading styles noisy. This can become even more convoluted with complex states.

jsx
const StyledButton = styled.button`
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;

  ${props =>
    props.primary
      ? css`
          height: 60px;
          background-color: darkslateblue;
        `
      : css`
          height: 40px;
          background-color: whitesmoke;
        `}
`;

If you're using Prettier for code formatting like We do, you'll end up with a monstrosity like you see above. Monstrosity is a strong way of putting it, but we find the indentation and formatting really difficult to read.


There's a better way: vanilla CSS

The solution was with us all along: CSS custom properties (AKA CSS variables). Well, not really. When the methods I've covered above were established, CSS custom properties weren't that well supported by browsers. Support these days is pretty much green across the board (unless you still need to support ie11).

After making the journey through using SCSS to Styled Components, I've come full circle back to vanilla CSS. I feel like there's an emerging trend of sticking more to platform standards with frameworks like Remix and Deno adhering closer to web standards instead of doing their own thing. I think this will happen with CSS as well, we won't need to reach for pre-processors and CSS-in-JS libraries as much because the native features are becoming better than what they have to offer.

That being said, here's how I've approached styling React components with vanilla CSS. Well, mostly vanilla CSS. I'm using postcss to get support some up and coming features like native nesting and custom media queries. The beauty of postcss is that as browsers support new features, the tooling slowly melts away.

Values

Passing values into CSS with custom properties is straightforward. We can use variables in the style property:

jsx
function Button({ color, children }) {
  return (
    <button className="button" style={{ '--color': color }}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: var(--color);
}

Now you might be thinking "isn't this just inline styles with extra steps?", and while we are using inline styles to apply the variable, it doesn't come with the same downsides. For one, there's no specificity issue because we're declaring the property under the .button selector in the css file. Secondly, all our styles are co-located, it's just the value of the custom property that's being passed down.

This also makes it really convenient when working with properties like transforms or clip-paths where you only need to dynamically control one piece of the value

jsx
// All we need to pass is the value needed by the transform, rather than
// polluting our jsx with the full transform in the inline style
function Button({ offset, children }) {
  return (
    <button className="button" style={{ '--offset': `${offset}px` }}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;
  transform: translate3d(0, var(--offset), 0);
}

There's way more you can do with CSS custom properties, like setting defaults and allowing overrides from the cascade for any components that compose one another to hook into, like a "CSS API". This article from Lea Verou does a great job at explaining this technique.

States

The best way We've found to deal with component states and variants with vanilla CSS is using data attributes. What we like about this is that it pairs nicely with the upcoming native CSS nesting syntax. The old technique of targeting BEM modifiers with &--modifier doesn't work like it does in pre-processors. But with data attributes, we get similar ergonomics

jsx
function Button({ color, size, primary, children }) {
  return (
    <button className="button" data-size={size} data-primary={primary}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  &[data-primary='true'] {
    background-color: var(--colorPrimary);
  }

  &[data-size='small'] {
    height: 30px;
  }

  &[data-size='medium'] {
    height: 40px;
  }

  &[data-size='large'] {
    height: 60px;
  }
}

Have a play with the example button component here:

This looks similar to how modifiers are written using BEM syntax. It's also much more straightforward and easy to read than the Styled Components function syntax. The one downside is that we do gain a level of specificity that we don't with BEM modifiers using the &--modifier pattern, but we think that's an acceptable tradeoff.

It may seem kinda weird at first to use data attributes for styling, but it gets around the problem of messy string concatenation using classes. It also mirrors how we can target accessibility attributes for interaction-based styling, for example:

css
.button {
  &[aria-pressed='true'] {
    background-color: gainsboro;
  }

  &[disabled] {
    opacity: 0.4;
  }
}

We like this approach because it helps structure styling, we can see that any class is styling the base element, andy any attribute is styling a state. As for avoiding style clashes, there are better options now that automate the process like CSS Modules which is included out of the box in most React frameworks like Next.js and Create React App.

Of course, these techniques don't require you to only use vanilla CSS, you can just as easily combine them with CSS-in-JS or a pre-processor. However with new features like nesting and relative colors I think it's becoming less necessary to reach for these tools.

Our entire website is styled using these techniques, showcasing their effectiveness in real-world applications.