8 min read

The Two-Tree Problem with Styling on the Web

by Daniel Nagy @danimal_channel
Two identical trees with a brick wall between them, one without leaves and one with beautiful foliage, digital art
Expand for the tl;dr

TL;DR

In this article, I discuss what I call the "two-tree problem" with styling modern web apps. The problem arises from the separation of structure and style.

I suggest that while CSS is a well-designed language for styling, the cascade and separation of styles from HTML may have been mistakes.

I then examine some popular styling libraries, including Emotion, Styled Components, Tailwind, and UI Box, assessing their effectiveness in addressing the two-tree problem.

I make a case for inline styles as a potential solution, emphasizing their simplicity. I acknowledge the limits of inline styles but suggest that these limits might not be significant issues for modern web development.

A lot of people like to complain about styling on the web. I don't blame them; the developer experience for styling elements in modern web applications isn't very good. However, while many people would agree that there is a problem, I'm not sure there is a consensus on how to solve the problem.

In this blog post, I will give my opinion on what I believe is the root of the problem with styling on the web. I will also shine a light on some popular libraries to see if they solve this problem or not.

What is the Two-Tree Problem?#

Simply put, the two-tree problem with styling on the web is that, in order to create a (pretty) document, you need two separate yet co-dependent trees. I'm quite literally referring to the DOM and CSSOM.

Separating structure and style creates a cognitive burden. A change in one tree may require a change in the other tree. You end up fighting a losing battle to keep the two trees synchronized. The following is an example of the two-tree problem:

<style>
  span {
    color: red;
  }
</style>

<span>This text is red!</span>

If you change the span tag to a b tag, for example, the styles will break. The point is that CSS selectors are dependent on HTML. Changing the HTML may break the styles. Because these are defined in different places with no explicit dependency, it becomes a cognitive burden.

Was CSS a Mistake?#

In the early days of the web, there was no way to style a document. To make matters worse, browsers did not agree on how to render a document. So the same document would look different in different browsers.

The lack of styling for web pages was a major pain point for web developers. Because of this, browsers started implementing their own methods for styling documents. It became apparent that the web needed a standardized way of styling a document.

More than a few competing methods for styling a document were created. Anyone of them could have just as easily become the standard. For a trip down memory lane, I suggest reading Zack Bloom's blog post, The Languages Which Almost Became CSS.

... it happens that many of these other options include features which developers would love to see appear in CSS even today. — Zack Bloom

The cascading feature of CSS was believed to be important because it gave the end user the power to control the style of the document (not just the web developer). In my opinion, the importance of this was overestimated.

As a web developer myself, I will admit that this does make styling third-party UI possible. But I would consider using CSS to reach into the internals of someone else's UI to be a bad practice and one that is fragile at best.

So, do I believe CSS was a mistake? The somewhat disappointing answer to this question is, yes and no. As a language for styling, I think it is a well-designed language. Its declarative syntax makes styling an element based on its state remarkably simple. Just try replicating this behavior in JavaScript, and you'll see what I mean (if only JavaScript had pattern matching... sigh).

However, I do believe an argument can be made that the cascade and the separation of styles from HTML were mistakes. To be fair, though, the web in the 1990s was a different landscape than it is today. Who knows? Maybe in another 30 years we will all be wearing devices on our heads and websites will be 3D immersive experiences.

How do We Fix the Two-Tree problem?#

Ok, separating styles and HTML is the source of a lot of pain, but what can we do about it? Well, you could use inline styles (bear with me). Here is the example from earlier, rewritten using inline styles.

<span style="color: red;">This text is red!</span>

In this example, if we change the span tag to a b tag, then our styles still work. We have a single tree for structure and style. This code is easy to refactor. Remove the element and remove its styles. The importance of this is underestimated for modern web development.

I know a lot of you are rolling your eyes right now because inline styles do not have feature parity with CSS. For example, you cannot use states like :hover or media queries with inline styles. This is a valid concern, but maybe it's not really an issue? I'll circle back to this, but first I want to look at some popular libraries for styling and see if they solve the two-tree problem. For the impatient, here is a table:

LibraryOne-TreeComposableFramework Agnostic
Emotion
Styled Components
Tailwind (Atomic CSS)
UI Box (Styled System)

In addition to the two-tree problem, I also want to test if these libraries are composable. To test if these libraries are composable, I will create a text component and see if I can use composition to create a header component that renders the text component with a larger font size.

Emotion#

Let's go alphabetically and start with Emotion. Because Emotion supports nested selectors, it fails the one-tree test, in my opinion. For example, the following is possible with emotion:

const App = () => (
  <div
    css={css`
      span {
        color: red;
      }
    `}
  >
    <span>This text is red</span>
  </div>
);

In this example, we still have two trees. We are able to arbitrarily select any child element and style it. Quite literally, this is CSS-in-JS.

Now let's test composability. Here's a simple text component using Emotion:

import { css } from "@emotion/css";

const Text = ({ css: _css, ...props }) => (
  <span
    css={css`
      font-size: 16px;
      ${_css}
    `}
    {...props}
  />
);

Let's now see if we can create a header component from our text component.

const Header = ({ ...props }) => (
  <Text
    css={css`
      font-size: 28px;
    `}
    {...props}
  />
);

We get composition with emotion, but it requires the composition to take place within the css template literal.

Styled Components#

Next, let's take a look at Styled Components. First, does it solve the two-tree problem?

const Div = styled.div`
  span {
    color: red;
  }
`;

const App = () => (
  <Div>
    <span>This text is red</span>
  </Div>
);

Just like Emotion, Styled Components is just CSS disguised as JS, so it fails this test, in my opinion.

Let's see if Styled Components is composable.

const Text = styled.span`
  font-size: 16px;
`;

const Header = styled(Text)`
  font-size: 18px;
`;

Styled Components is composable. The one thing that stands out here, though, is that you're trapped within the API of Styled Components. You cannot mix styles with your component logic. They must be defined separately. Personally, I dislike this.

Tailwind (Atomic CSS)#

Atomic CSS is a different approach to styling modern web apps. The premise of atomic CSS is that you have a 1-to-1 mapping between class names and styles. Typically, this is touted as scalable because the size of your CSS grows disproportionally with the size of your application. This is true, but what is overlooked, and why I think Tailwind has become so popular, is that atomic CSS solves the two-tree problem.

Let's put that claim to the test.

<div className="text-red-500">This text is red</div>

In this example, I can only apply styles to the current element. I cannot arbitrary select another element in the document. By the power vested in me by the internet, I now pronounce atomic CSS a one-tree design.

Let's now test if it is composable.

const Text = ({ className, ...props }) => (
  <span className={`${className} text-sm`} {...props} />
);

const Header = () => <Text className="text-large" />;

Uh oh, we have a problem. Which class wins? Well, that depends on the order in which the classes appear in the generated CSS. So Tailwind, and more generally, atomic CSS, is not composable.

UI Box (Styled System)#

The last library I want to look at, and probably the least familiar to my readers, is UI Box. UI Box is a derivative of a technology known as styled system, an alternative approach to styling components. Unlike Emotion or Styled Components, it doesn't just colocate CSS and call it a day. Rather, it provides a new primitive for styling that feels native for component-based applications.

Let's see if UI Box solves the two-tree problem.

<Box color="red">This text is red</Box>

As you can see, style props are first-class citizens on the Box component. Styles can only be applied to the current element. Therefore, styled system is a one-tree design.

If the Box component seems strange to you, that is ok. I didn't really understand it at first, either. But the name box comes from the CSS box model. The box model is used by browser engines to render HTML. You can think of the Box component as a new primitive for styling using the box model.

Let's see if it is composable.

const Text = (props) => <Box fontSize={16} {...props} />;

const Header = () => <Text fontSize={28} />;

There's something special going on here. We are styling elements using familiar component APIs. The mental model for styling an element is the same as passing props to a component. This allows for effortless composability and expressiveness. You can now actually call it UI as a function of state.

I use UI Box for my blog, and it is undoubtedly the best developer experience for styling that I have ever used. I think the community needs to take a second look at styling elements this way. However, I'd be remiss if I said UI Box was perfect. For starters, it's not framework-agnostic, though it could be implemented in any component-based framework.

INFO
I no longer use UI Box. My website is now 99% inline styles.

There are some problems that are unique to UI Box and some that are not. A couple problems unique to UI Box are:

  1. It is missing many style props. The workaround is to fallback to inline styles for missing properties.

  2. It generates CSS under the hood instead of using inline styles. The main issue with this is performance when doing animations. Again, the workaround is to use inline styles when doing animations. However, I would argue there are additional problems with generating CSS instead of just using inline styles.

Then there are some issues that are not unique to UI Box:

  1. You will occasionally experience prop collisions. For example, the img tag has height and width properties. So the following technically results in a collision:

    const Image = (props) => <Box is="img" {...props} />;
  2. The is prop can only be set once. Placing limits on composition.

If you're interested, Nick Saunders goes into more detail about the issues with polymorphic components in his post, Function asChild.

UI Box reminds me a bit of what Marc Andreessen attempted with the Mosaic web browser. Which was to extend the HTML language with semantics for styling. He got a lot of hate for the idea, but I believe he was on to something.

Inline Styles#

I promised earlier that I would get back to inline styles, so let's talk some more about inline styles. Most people throw shade at inline styles because they are significantly less functional than CSS. However, with JavaScript, you don't need most CSS features. To help prove this point, let's take a look at states like :hover, :active, and :focus.

These states can be implemented in JavaScript. For example, I created a React hook that will give me information about an element's state. I can then use that information to change the style of the element.

Using this hook would look something like this:

const Link = (props) => {
  const [element, setElement] = useState(null);
  const { hover } = useElementState(element);
  return (
    <Text color={hover ? "red" : "black"} is="a" ref={setElement} {...props} />
  );
};

It is a little annoying to obtain a reference to the element, but I don't need an element's state that often. What's neat is that I can perform logic and styling using the same state.

You may be worried about SSR. Don't be. There is no user on the server to change an element's state.

So, do you still need CSS at all? Unfortunately, yes. There are still a few things JavaScript can't do that you need CSS for. What are those things?

  1. Using custom fonts. As far as I know, you need CSS to create an @font-face rule.
  2. Using media or container queries in a way that works with SSR.
  3. Styling user agent shadow DOM. For example, input placeholder text.

I may be missing something, but if I am, I haven't noticed. Number two is the biggy, and one that I don't really have a solution for. But I can tell you how I work around using media queries on my blog.

For media queries like prefers-color-scheme I use CSS variables. I render a style tag that sets some variables, something like:

<style>
  :root {
    --text-color: black;
  }

  @media (prefers-color-scheme: dark) {
    :root {
      --text-color: white;
    }
  }
</style>

Then in my text component, I would use the CSS variable.

const Text = (props) => <Box color="var(--text-color)" />;

I have some abstractions on top of this, but you get the point.

Now, for media queries like @media (max-width: x) and @container (max-width > x), I've just decided that I don't need (or want) them. Width-based media queries create rigid layouts. Rigid layouts are not flexible and can become a maintenance burden. The reality is that we live in a world of infinite screen sizes, and breakpoint-based design is so 2015. Instead, I use fluid layout algorithms such as flexbox and gird.

At this point in my career, I would definitely advocate for using inline styles. With inline styles, you can render your HTML in any document, and the styles will work without having to dynamically insert style tags or load external style sheets.

You either die writing CSS or you live long enough to see yourself writing inline styles.tweet

If you feel you cannot live without pseudo-classes or media queries, then you should check out css-hooks.

What is the Future?#

Just like React was a course correction from AngularJS, I believe inline styles are a course correction from CSS. I'd like to see browser vendors take a second look at styling web pages. Perhaps adding more functionality to inline styles to close the feature gap with CSS.

What do you think the future will be? A new DSL that isn't exactly HTML or CSS, but one that allows creating styled documents in a declarative way? Or will AI just solve all of our problems and make web developers obsolete?

Written by Daniel Nagy

Daniel is a software engineer and full-stack web developer. He studied computer science at Ohio University and has been doing web development and hybrid mobile app development for over 8 years.

Comments#

INFO
You will not receive notifications for new comments. If you are waiting for a reply, please check back periodically.
Nick Saunderscommented on Mar 12, 2024

Great post, Dan! Your mention of my work is an honor, since your interest in this topic was a big part of what inspired my search for CSS Hooks.

One thing I'd like to share is that emulating pseudo-classes using JavaScript is trickier than I once thought. The hover state is relatively straightforward, although it would be easy to forget to matchMedia("(hover:hover)") before listening to the element's mouseenter and mouseleave events. But then there are some pseudo-classes like :focus-visible where it's difficult (for me at least) to imagine what the JavaScript equivalent would even look like - and I'd also be worried about consistency with each browser's native implementation.

Another note - regarding component state - is that, as you know, each change causes the component to re-render. This isn't necessarily expensive; but, if you only use that state in order to toggle a CSS value, you might as well save whatever the cost is.

In any case, thanks for sharing your analysis, and much respect for your pragmatic approach to this issue. Keep up the great work!

Daniel Nagycommented on Mar 12, 2024 • Edited

Thanks for taking the time to comment, @Nick Saunders!

As you pointed out, if implementing pseudo-classes in JavaScript, it is important that the behavior is identical to that of CSS (for any pointer device). Maybe it's worth creating a package in case anyone else is interested in a pure JavaScript solution 🤔.

You are right that tracking an element's state in JavaScript will cause a re-render of the component. Personally, I would not be concerned about this, and I do sometimes use the state to perform logic as well as styling.

I'll be keeping an eye on css-hooks and may start using it if I get rid of UI Box!

Nick Saunderscommented on Mar 14, 2024
Regarding Tailwind composition, you might be aware of https://www.npmjs.com/package/tailwind-merge; but, at 25kB (minified), it's clearly a band-aid solution and still doesn't solve the problem for non-Tailwind class names.
Daniel Nagycommented on Mar 14, 2024
Yeah, not ideal.
Markdown enabled