Why this is hard
CSS-in-JS libraries tend to solve the problem of colocating and scoping styles with their components by deferring style sheet construction until runtime. Component styles are parsed and inserted into the DOM when the component code is executed. The benefit of this approach is exceptional dynamism. Styles can change as needed to match the inputs of the component. However, this flexibility comes at a cost:
- Styles need to be parsed by the library at runtime, which requires bundling a css parser
- Style are double parsed (once by the library, once by the browser when inserted)
Alternatively there have always been pure CSS methodologies like BEM, OOCSS, and others, for structuring maintainable CSS. However, as methodologies, they require manual and consistent discipline to succeed. We feel that styled-components and Emotion have made meaningful strides in improving ergonomics and quality of large CSS code bases. astroturf is our exploration into capturing those benefits without any of the costs.
Before describing our solution, it'll be helpful to lay down the constraints we set for ourselves, since they informed the choices we made.
1. Work with existing CSS tooling
It's important that any solution be interoperable with the existing CSS ecosystem. This means it works with our choice of preprocessor (Sass), as well as working with build tooling.
In particular, webpack and its ecosystem already have a well-defined and robust set of tools for extracting, optimizing, and code-splitting CSS. These are hard problems that already have good answers.
2. No runtime parsing or insertion of CSS
Stylesheets need to be created as build artifacts with no additional runtime parsing. Modern single-page apps already have a lot to do at runtime and we do not want to add more work, if we can avoid it.
The first challenge is finding a way to deal with global scoping of CSS. If we are going to have component-defined and scoped styles, we need to ensure that unrelated styles don't affect our component. Luckily css-modules handles this for us and provides an interface for importing, exporting and composing scoped styles. It also has the important benefit of being built into webpack's css-loader by default.
For those unfamiliar with how css-modules work, it processes a stylesheet, by hashing the class names, ensuring that they are unique. Turning this:
With our CSS neatly encapsulated, we move the actual style declaration into our component file as follows:
To accomplish this, astroturf uses a simple trick. While webpack is processing the Button.js file, an astroturf loader looks over the source for CSS template tags. When it locates one, it reads the text in between the backticks and emits it as a separate file that webpack sees: Button-styles.module.css. Now our styles are extracted into their own file and they make their way through the webpack pipeline, like any other CSS file. The best part of this approach is that we automatically get to use all of the webpack tooling for processing CSS, including minification, integration with preprocessors like Sass, and even code splitting via webpack's splitChunks optimization.
We aren't quite done, though. Our Button still needs to have access to those styles. To accomplish this, the loader replaces the CSS template tag with a require() to our new file, giving us:
With a small additional compile step, we can generate the above automatically from a dedicated css prop (as popularized by Emotion). Now we have a concrete connection between our styles and component with no styling runtime!
From here, it's a bit more work to a full styled('button') API, but the underlying approach remains the same:
- Extract the CSS strings to separate files
The real meat of the styled API is more around React-specific component factory helpers. It's interesting in its own right but is mostly orthogonal to our static styling approach.
NNow the hard stuff. Dynamism, the ability to adjust styling based on runtime changes, is a core value-add of styled-components but is a stumbling block for us in our attempt to build a fully static version of the API. To address this, it's helpful to break down the common use cases for dynamic behavior and cover the solutions independently.
Component variants and modifiers
A common pattern in well structured CSS, is the concept of a component "variant". Consider the Button in your UI library. There are generally some base styles and a few specific variations, often with names like "primary", "secondary", "danger", etc. The level of granularity needed for switching styling is fairly broad, and is usually neatly organized under another CSS class. For instance, Bootstrap has <button class="btn btn-primary">, where btn contains the common base styles to all buttons and btn-primary the the bits specific to that variant of button. The React component version might look something like:
Notice that the crux of interoperability is choosing a specific CSS class based on the value of a prop. There are a few possible API's for doing this sort of mapping that astroturf has tried out over time, but the one we like the most is a convention based approach inspired by nyancss (formally decss). We take a component like:
and compile it to:
By using a naming convention, we can easily map props to classes without a lot of analysis. We rely on a common naming pattern of the form: propname-propvalue. While a bit less CSS-in-JS feeling, there is a very nice benefit to this convention over other approaches. Because we know which classes map to which props, these attributes can be automatically stopped from being passed down to the base button element. This is still an unsolved issue for styled-components and requires a custom rejector function in Emotion!
Sharing static values
The other main use-case and interop point between JS and CSS is for sharing values. The simplest of these cases being sharing a static value, such as a height or transition duration. For these cases runtime behavior is often not actually required, since the shared value is static but siloed in its language's environment. astroturf offers two approaches addressing this concern.
Prop based styling
Use cases that require true dynamism are often less about sharing values across language and more about using runtime values in styles. Following our example above, let’s move duration from a constant to props:
Here we can't be confident at build time what the value of duration is because it's not static. It may be 1000 or 200 depending on what the consumer passes into the component. We also can't map duration to some predefined set of CSS classes like with variants, because the value is unbounded. Luckily, CSS does provide a way to specify dynamic values without us having to create new style declarations for every new value. For that we need CSS custom properties.
Instead of hardcoding the value into the CSS, we replace the duration interpolation with a reference to a generated css custom property:
At this point our style sheet is complete, and safe to extract as before into its own file.
The compiled code looks like this:
Notice the inline style addition here? This approach lets us fully specify values at runtime without needing to create new CSS classes for each prop iteration or result to full inline styling.
There is a lot more to astroturf's API that we did not cover here. For example, styled components can be interpolated and referenced in other components and in selectors. There are a few interesting strategies we use to accomplish this at build time, but they all sit on top of this core approach of leveraging css-modules and build-time analysis. The result is a styling library that captures the benefits and developer ergonomics of true CSS-in-JS libraries with no user-facing costs.
If this or any other aspect of of our little library appeals to you check it at: https://github.com/4Catalyzer/astroturf