Skip to main content

A modern approach to styling in React

00:08:35:70

When I first tried CSS-in-JS libraries like Styled Components and Emotion, the thing that felt right about it was passing values or state directly into the styles for a component. It really closed the loop with the concept of React where the UI is a function of state. While this was a definite advancement over the traditional way of styling React with classes and pre-processed CSS, it still had its problems.

To highlight some examples, I'll break down some typical examples using two main types of dynamic styles you'll run into with React components:

  1. Values: like a color, delay, or position. Anything that represents a single value for a CSS property.
  2. States: like a primary button variant, or a loading state each having their own set of associated styles.

Where we are today

Before we get started, for comparison I'll be using SCSS (with BEM syntax) and Styled Components in my examples for how styling is typically approached in React. I won't cover CSS-in-JS libraries that deal with writing CSS as JavaScript objects. I think there are already good solutions out there (I'd recommend Vanilla Extract) if you prefer having type checking and living more fully on the JavaScript side of things. My solution is more for those of us that like writing CSS as CSS, but want to respond to the reactivity and state of components in a better way.

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

Values

Using vanilla CSS, or pre-processed CSS by means of LESS or SCSS, the traditional way of passing a value to your styles on was to just use inline styles. So if we have a button component that allows a color, it would look something like this:

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

The problem with this approach is that it brings with it all the problems of inline styles. It now has higher specificity making it harder to override, and the styles aren't co-located with the rest of our button styles.

CSS-in-JS (in the case of Styled Components or Emotion) solved this problem by allowing dynamic values like this to be directly 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'd use css classes and concatenate strings. This always felt messy and clunky, but it works nicely on the css side, particularly if you're using a naming convention like BEM along with a pre-processors. Say we have small, medium, and large button sizes, and a primary variant, it might look something 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;
  `}
`;

It's not terrible, but the repeated functions to grab props gets repetitive and makes reading styles quite noisy. It can also get way worse depending on the type of state. If you have separate but mutually exclusive states sometimes it calls for a ternary expression that can end up looking even more convoluted and difficult to parse.

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 I do, you'll end up with a monstrosity like you see above. Monstrosity is a strong way of putting it, but I 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 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

A really neat trick I've found for passing values into css is using custom properties. It's pretty simple, we can just drop variables into the style property and it works.

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 I've found to deal with component states and variants with vanilla CSS is using data attributes. What I 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 I 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;
  }
}

I 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.

The entirety of this website is styled using these techniques.