Inline Styles on Steroids

9min • 19 May 2024
100% human-written

Almost 8 years ago, I published my first article Style as a Function of State (new tab) introducing the first stable version of Fela (new tab). 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 (new tab) (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 (new tab) (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 (new tab), no static CSS extractions and even Tailwind (new tab) 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 (new tab) only (new tab) one (new tab) thinking that way.

According to Lea Verou (new tab) from the CSS Working Group (new tab) (CSSWG), native browser support is planned (new tab) 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 (new tab) by Nick Saunders (new tab).

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>
Hover me

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 (new tab), emotion (new tab) 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:

  1. 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.
  2. 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 (new tab) wrote two great (new tab) articles (new tab) about it!
Edit: Thanks to a tweet (new tab), 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 (new tab).
Thanks to the simple API provided by css-hooks, I was actually able to support (new tab) most of the existing Fela plugins (new tab) 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 (new tab) 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

To The Top
Picture of Robin Weser

Thanks for reading!

Comments or questions? Reach out to me via Twitter (new tab) or Mail (new tab).
Subscribe to my newsletter to get notified of new articles!

Enjoyed the article?