Inline Styles on Steroids
Almost 8 years ago, I published my first article Style as a Function of State introducing the first stable version of Fela. It was exciting times for me. The React ecosystem was changing rapidly. New styling solutions were released every month and many clever people contributed ideas that eventually lead what Fela is today.
With Fela, styling was a solved problem for me for the past 8 years and I still happily use it for many websites and apps. But, React changed a lot since then and, unfortunately, the direction it's heading is not really compatible with Fela's design.
The Problem
Note: I don't want to go into details, but please ping me if you're curious and I might write an extra article about those issues.
To be more precise, the new server-first architecture with React Server Components (RSC) combined with streaming rendering is pretty much a death sentence for Fela. In fact, we're not the only library. Many runtime CSS-in-JS libraries struggle to add support for RSC.
I can't speak for the rest, but in Fela's case it's pretty much impossible to solve it.
The decision to output Atomic CSS came with a heavy dependence on a universal cache to ensure deterministic results preventing a Flash of Unstyled Content (FOUC) and hydration mismatches.
Important: This doesn't mean, that you can't use Fela at all or that you have to refactor your Fela applications immediately, but if you really want to use RSC and streaming rendering, I'd recommend to look for an alternative. I will present a potential alternative in this article as well.
Starting Over Again
Alright. Here we go again, searching for a styling solutions that works with modern React. Since shipping the first version of Fela, a lot has happened in the styling space. Back in 2016, there was no styled-components, no static CSS extractions and even Tailwind was unheard of at the time.
I had plenty of options, but I have to admit, I got quite used to Fela and didn't really like most of the solutions. Yes, I'm also not a big fan of Tailwind. It's not that I don't see the benefits, I just don't like the string-centric ergonomics and the authoring experience that comes with it.
So, instead of evaluating and comparing libraries, I wanted to start over and instead think about how my ideal solution would look like.
Long story short, I ended up thinking a lot about inline styles again. What if only they had support for things like pseudo classes, media and container queries? I probably wouldn't have written Fela in the first place. Turns out, I'm not the only one thinking that way.
According to Lea Verou from the CSS Working Group (CSSWG), native browser support is planned and coming at some point, but it's not trivial to implement.
Until then, we won't get proper CSS features in inline styles. Or?
CSS Hooks
What if I told you, it's actually possible? All it takes is a neat little CSS Variable trick. It's the core mechanism behind a new library called css-hooks by Nick Saunders.
It allows us to use native CSS features such as pseudo classes and media classes with standard inline style. It is framework-agnostic and works with different libraries including React and Solid. The runtime is minimal and primarily enhances the authoring experience by transforming the style object in order to support these features.
It does not generate CSS and there's no compiler involved. And, since it's pure inline styles, it works great with streaming and RSC.
CSS Variable Trick
Nick wrote a great article where he explains the thought process behind creating css-hooks and how it works internally.
To sum it up briefly, the CSS Variable specification allows to set a fallback value that is used when the variable is not set e.g. --var(--my-variable, red)
. If the variable is set to initial
, the fallback value is used, if it's set an empty value, that value is used which in return has no effect on the declaration.
That behaviour is used to implement pseudo classes and media queries.
* {
--hover-off: initial;
--hover-on: ;
}
:hover {
--hover-off: ;
--hover-on: initial;
}
<div style="color: var(--hover-on, red) var(--hover-off, blue)">Hover me</div>
The Gist
Disclaimer: This article uses an old version 1.x.x
and the API changed slightly, but there's a convenient helper for seamless migration.
Setting up css-hooks is simple. All we have to do is pass an object with all the hooks we want to enable.
It returns a tuple with a static CSS string and a css
helper similar to the one provided by react-fela, emotion and other libraries.
import { createHooks } from '@css-hooks/react'
// stylesheet is a static CSS string
// it needs to be inserted at the root
const [stylesheet, css] = createHooks({
// There's also @css-hooks/recommended that helps with creating hooks
':hover': ':hover',
':active': ':active',
})
const style = css({
color: 'red',
':hover': {
color: 'blue',
},
})
Drawbacks & Pitfalls
Of course, no solution comes without drawbacks. In the case of css-hooks, there are primarily two issues that we might run into:
- Pseudo elements:
Unfortunately, this trick does not work on pseudo elements such as::before
,::after
or::placeholder
. The only workaround is to sprinkle in some extra CSS to add those.
That said, I rarely ever need pseudo elements in the first place and when I do, it's mostly design system related things like styling checkboxes or radio buttons. - Descendent selectors:
It supports selectors, but they might not work the way you'd expect them to.
All selectors eventually target the element that receives the style object. If we want to conditionally style an element based on its parent, we have to pass the style to the element directly using something like.special-parent &
instead of targeting the child from the parent using> .child
.
This might be confusing at first, but I got used to it quickly and it sometimes even helps to think about components in isolation.
Performance: You might wonder why I didn't mention performance. We've been told inline styles are slow, so why should we even consider inline styles again?
Daniel Nagy wrote two great articles about it!
Edit: Thanks to a tweet, I learned about a potential performance issue with CSS varibles in Chromium-based browsers. Hopefully this will be fixed soon.
Bridging Fela Ergonomics
While I was pretty impressed and really enjoyed using css-hooks for my website rewrite, I quickly started to miss some of the Fela ergonomics that I got used to over the last 8 years.
Therefore, I started bridging some of the functionality and ended up creating a little tool belt that extends css-hooks' core and adds features such as plugins, keyframes and theming support.
Introducing Brandeur
Staying true to my naming convention, I called it brandeur.
Thanks to the simple API provided by css-hooks, I was actually able to support most of the existing Fela plugins out of the box.
import { createHooks } from 'brandeur'
import responsiveValue from 'brandeur-plugin-responsive-values'
import customProperty from 'fela-plugin-custom-property'
const breakpoints = {
'@media (min-width: 480px)': '@media (min-width: 480px)',
'@media (min-width: 1024px)': '@media (min-width: 1024px)',
}
const theme = {
colors: {
foreground: {
primary: 'red',
},
},
}
const marginX = (value) => ({
marginLeft: value,
marginRight: value,
})
// brandeur is a superset of css-hooks mirroring its API
const [stylesheet, css] = createHooks({
hooks: {
':hover': ':hover',
':active': ':active',
...breakpoints,
},
plugins: [
responsiveValue(Object.keys(breakpoints)),
customProperty({ marginX }),
],
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
},
},
})
const style = css(({ theme, keyframes }) => ({
color: 'blue',
animationName: keyframes.fadeIn,
fontSize: [20, , 16],
marginX: 4,
':hover': {
color: theme.colors.foreground.primary,
},
}))
Primitive Components
In addition to that, I also created tehlu which further extends brandeur building a system that provides a set of primitive layout components that are useful when bootstrapping a project.
Caution: Tehlu is still work in progress and might introduce breaking changes.
import { createSystem, createBox, createText } from 'tehlu'
const system = createSystem({
// ... brandeur config
baselineGrid: 4,
typography: {
body: {
fontFamily: 'Inter',
fontSize: 16,
},
title: {
fontFamily: 'Futura',
fontSize: 26,
},
},
})
const Text = createText(system)
const Box = createBox(system)
const App = (
<Box paddingInline={4} gap={2} justifyContent="center">
<Text variant="title">Hello World</Text>
<Text variant="body">Welcome to my page</Text>
</Box>
)
Conclusion
It turns out, inline styles are more powerful than we thought. After more than 8 years creating and using CSS-in-JS solutions, I'm happy to be back where I started.
I really enjoyed the journey and am more than grateful for everything that my open source contributions enabled for me. Fela is, was and probably always will be my biggest non-work related project and I am proud of what it became.
At the same time, it's great to see the ecosystem evolve with clever new solutions such as the CSS Variable trick. I'm excited to be able to simplify my stack a lot by just using inline styles.
My website is now fully powered by inline styles and I can't wait to drop css-hooks for native browser support in the future!
Changes
24/05/2024: Added a disclaimer regarding the css-hooks version used in my examples as well as a note about potential performance implications