The first time I experimented with CSS-in-JS libraries such as Styled Components and Emotion, what resonated with me about using these libraries was the ability to directly feed values or state into a component's styles. This seamlessly integrated with React's principle that the UI is a reflection of its state. Though this approach marked a significant progression from the conventional method of styling React with classes and pre-processed CSS, it wasn't without its challenges.
To shed light on this, I'll dissect a few representative cases using the two primary forms of dynamic styles commonly encountered in React components:
- Values: like a color, delay, or position. Essentially, any singular value associated with a CSS property.
- States: such as a main button design, or an active loading state, each with their unique style configurations.
Where we are today
Before diving in, I'll use SCSS as a reference for our comparisons (with BEM syntax) and Styled Components in my illustrations to showcase the conventional React styling approaches. I won't delve into CSS-in-JS libraries that require you to frame CSS as JavaScript objects, as there are sufficient and effective solutions available for that (I'd recommend Vanilla Extract) for those who favor type checking and have a stronger inclination towards the JavaScript domain. My approach caters to individuals who appreciate penning CSS in its native form, yet seek a more efficient way to adapt to the dynamism and state of components.
If you're well-acquainted with the issue at hand, jump directly to the solution.
Values
With plain CSS, or pre-processed variants such as LESS or SCSS, the conventional method to relay a value to your styles was through inline styling. For instance, if we have a button component that permits a color customization, it might appear as follows:
function Button({ color, children }) {
return (
<button className="button" style={{ backgroundColor: color }}>
{children}
</button>
);
}
The drawback of this method is that it inherits all the issues associated with inline styles. The specificity increases, making overrides more challenging, and the styles are not situated alongside our other button styles.
With CSS-in-JS solutions, particularly in tools like Styled Components or Emotion, these challenges were addressed. They facilitated the direct integration of such dynamic values as props.
// 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
In the past, the norm was to utilize CSS classes and string concatenation. While this method felt inelegant and cumbersome, it was effective on the CSS end, especially when paired with a naming strategy like BEM and pre-processors. For instance, if we consider button sizes such as small, medium, and large, along with a primary variant, the representation could be as follows:
function Button({ color, size, primary, children }) {
return (
<button
className={['button', `button--${size}`, primary ? 'button--primary' : null]
.filter(Boolean)
.join(' ')}
style={{ backgroundColor: color }}
>
{children}
</button>
);
}
.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 presents itself neatly and organized. I've consistently appreciated the strategy of employing nesting in SCSS to combine elements and modifiers, especially when adhering to the BEM convention.
While our SCSS shines in organization, our JSX seems to be grappling with chaos. The string concatenation for the className
is far from elegant. Using the size property is manageable since it simply appends the value to the class. However, the approach to the primary variant is less than ideal. And let's not overlook the peculiar filter(Boolean)
method, added merely to avoid an extra space for non-primary button class listings. Though there are methods to ease this, like the classnames
package on NPM, they merely provide a slight reprieve from the underlying issue.
In contrast to dynamic values, handling states with Styled Components feels somewhat more awkward and less streamlined
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 it's not the worst, having to repeatedly access props can clutter the code and disrupt the reading flow. When styles are bogged down with these repetitive structures, it detracts from the clarity. Moreover, when you deal with distinct states that are mutually exclusive, things can get tricky. Resorting to ternary expressions in such cases can further complicate the styling, making it a challenge to decipher at a glance.
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;
`}
`;
With Prettier as my code formatter of choice, the result can sometimes be less than ideal, as showcased earlier. While "monstrosity" might be an exaggeration, I genuinely believe the indentation and layout can make comprehension quite challenging.
There's a better way: vanilla CSS
CSS custom properties, commonly known as CSS variables, have always been our saving grace. However, their importance wasn't as recognized before, primarily due to inconsistent browser support. Today, we're fortunate to have consistent support across almost all major browsers (with the exception being the old ie11).
As I ventured from SCSS to Styled Components, I eventually gravitated back to plain CSS. A notable shift I've observed in the development realm is the inclination towards platform-specific standards. Frameworks like Remix and Deno are now more aligned with web standards rather than crafting their unique solutions. I anticipate a similar trajectory with CSS, where developers might not have to rely heavily on pre-processors or CSS-in-JS libraries, mainly because the inherent features of CSS are continually evolving and often surpassing those auxiliary tools.
With that perspective, let's delve into how I've embraced styling React components using almost pure CSS. I say 'almost' because I still lean on postcss to leverage upcoming features such as native nesting and custom media queries. The elegance of postcss is its progressive nature; as browsers evolve to support newer functionalities, the need for such tools diminishes.
Values
A really neat method I've discovered for injecting values into CSS is leveraging custom properties. The process is quite straightforward: simply insert variables into the style attribute, and voila, it takes effect seamlessly.
function Button({ color, children }) {
return (
<button className="button" style={{ '--color': color }}>
{children}
</button>
);
}
.button {
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: var(--color);
}
You might be wondering, "Isn't this essentially inline styles, but with a twist?" Well, even though we utilize inline styles to introduce the variable, it doesn't carry the same pitfalls. For starters, there's no specificity concern, since the property is set under the .button
class in our CSS sheet. Also, all our stylings remain in one place; we're simply injecting the custom property's value.
What makes this approach particularly handy is its application to properties like transforms or clip-paths. In such cases, you might want to dynamically adjust just a fragment of the value, and this technique shines there
// 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>
);
}
.button {
border: 0;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: whitesmoke;
transform: translate3d(0, var(--offset), 0);
}
CSS custom properties offer a plethora of possibilities. For instance, you can set default values and permit overrides from the cascade, facilitating a kind of "CSS API" for components that intertwine or build upon each other. This versatility creates a robust framework for creating interconnected and flexible design systems. This article really excells at explaining this technique.
States
My preferred approach for handling component states and variants using plain CSS revolves around data attributes. This method aligns seamlessly with the forthcoming native CSS nesting conventions. While the traditional approach of targeting BEM modifiers using &--modifier
isn't replicated in the same way as with pre-processors, data attributes present a similar and efficient usability.
function Button({ color, size, primary, children }) {
return (
<button className="button" data-size={size} data-primary={primary}>
{children}
</button>
);
}
.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;
}
}
Experiment with the sample button component provided below:
Using this method feels reminiscent of how BEM syntax structures its modifiers. It definitely appears more intuitive and legible compared to the convoluted syntax of Styled Components. Granted, by employing this method, we introduce a slightly higher specificity compared to the BEM's &--modifier
approach. However, in my opinion, this is a reasonable compromise.
Leveraging data attributes for styling might initially strike you as unconventional. Yet, it effectively sidesteps the clutter that arises from string concatenation with class names. This strategy also aligns with the way we utilize accessibility attributes for styles based on interactions, as seen in the following example:
.button {
&[aria-pressed='true'] {
background-color: gainsboro;
}
&[disabled] {
opacity: 0.4;
}
}
I appreciate this method as it offers a clear framework for styling: class selectors denote the foundational style, while attributes indicate specific states. To steer clear of styling conflicts, contemporary solutions, such as CSS Modules, come to the rescue. What's even better is that popular React frameworks like Next.js and Create React App incorporate CSS Modules by default.
Naturally, these strategies aren't limited to pure vanilla CSS. They can seamlessly integrate with CSS-in-JS solutions or even pre-processors. Yet, with the introduction of advanced features such as nesting and relative colors, there's a diminishing need to depend on these external tools.