Styling Web Applications

2021-12-25
James Diacono
james@diacono.com.au

I like CSS. My first real job was fixing the CSS bugs in the website of a large video game retailer. I was paid well because I knew a lot of the hacks that made Internet Explorer 6 behave like Internet Explorer 7. Since then, browsers have improved a lot. They still have rendering bugs, but we no longer have to litter our stylesheets with cryptic workarounds.

CSS was released in 1996, 25 years ago. It was designed as a means of styling websites, and I think it does that quite well. But the last 20 years have seen the birth and rise of the web application, which is a different kind of beast. Understandably, there is some confusion over exactly what is a website and what is a web application. That is because there is really a spectrum. Parts of a website can resemble an application, and parts of an application can resemble a website.

Websites are content-driven. At this end of the spectrum, there is a clear separation between style & markup. The HTML is clean, semantic and accessible. The page is divided into <header>, <main> and <footer> elements. The CSS might fail to load, but the content always shines thru.

Web applications are behaviour-driven. They are made up of components, each of which encapsulates a small portion of the user interface. A component might be made up of several other components. A running web application may be visualised as a deeply nested heirarchy of component instances. This heirarchy continually reshapes in response to user interaction and other changing conditions. Each component is coded as a module, usually containing

Within a component, separation between style & markup is not so important. It is the separation between components that is valuable.


Often, a dialect of CSS is used to style components. Whatever language is used, it must eventually be delivered to the browser as CSS, because only CSS is fully capable of styling the DOM. Unfortunately, CSS was not designed with modularity in mind. All CSS rules on the page are global, affecting every element that they happen to match. CSS does not care about your component boundaries. There is no way for a component to opt out of unwanted rules intended for other parts of the application. There is no guarantee that a component that looks okay in one application will not be totally broken when used in another application. A good module has a clearly defined interface and protects itself from external interference. And good modularity is the only way to preserve the maintainability and reliability of a growing application. It is CSS, more than anything else, that is holding us back from making better web applications.

The solution is to never use global CSS. Every single rule must have an unguessable selector, and be applied only to elements that reference it explicitly. This is how many of us are doing it right now:


    div[data-scope=w5t89wy34] {
        background: red;
    }
    <div data-scope="w5t89wy34">My red box.</div>

The w5t89wy34 is a secret value known only within the scope of a single component. Rules defined in this way are guaranteed not to interfere with any other components, but it affords the component no protection from global rules, such as


    *,
    *:after,
    *:before {
        box-sizing: border-box;
    }

which is much celebrated. I think the time is ripe to dispense with such rules, along with CSS resets and normalisers. They can be applied piecemeal to the elements that need them, rather than dumped into the global scope.


JavaScript emerged a year earlier than CSS. JavaScript gave us the power to bring a webpage to life, responding to user interaction in all sorts of interesting ways. At that time, the main contender to CSS was JavaScript Style Sheets. As the name suggests, JSSS foresaw the huge potential of dynamic webpages, but sadly CSS did not. Microsoft refused to implement JSSS in Internet Explorer, so JSSS died a quiet death, leaving only CSS. This is how we model behaviour in CSS:


    .fruit {
        padding: 10px;
        border-bottom: 1px solid gray;
        color: black;
    }
    .selected {
        color: red;
    }
    <li class="fruit">orange<li>
    <li class="fruit selected">apple<li>

When apple was selected, it received the class of an additional rule that overrode the color declaration from the .fruit rule. This is inheritance at its worst. The .fruit rule has no way of protecting its integrity. Seen as a module, it is coupled with the .selected rule in the most brittle way. Worse, the direction of inheritance is not always obvious. The precedence of selectors is a complex topic, but you need to understand it to make sense of clashing declarations. In the above example, it is the order that the two rules appear on the page that determine their precedence. (Not, as you might expect, the order in the element's class attribute.) If the two rules were located in separate files, bundled in an unspecified order, there could be trouble.

The solution is to parameterise the .fruit rule.


    .fruit(selected) {
        padding: 10px;
        border-bottom: 1px solid gray;
        color: selected ? red : black;
    }
    <li class="fruit(false)">orange<li>
    <li class="fruit(true)">apple<li>

All of the aforementioned problems disappear. Clashing declarations can now be considered a programmer error, not a best practice. The .fruit rule can maintain its own integrity, rather than suffering from silent corruption. Maybe some day CSS will have this feature. For now, it can be simulated with a JavaScript function.


    function fruit_rule(selected) {
        return `
            .fruit_${selected} {
                padding: 10px;
                border-bottom: 1px solid gray;
                color: ${selected ? "red" : "black"};
            }
        `;
    }
    <li class="fruit_false">orange<li>
    <li class="fruit_true">apple<li>

CSS is so object-oriented that it actually has two kinds of inheritance. When certain properties, such as font-family, are applied to an element, they are also applied to descendant elements. The property does not stop cascading until it is redeclared.

If you squint, you can kind of see how this makes sense for styling websites. But no amount of squinting makes it suitable for web applications. This is because inherited properties will happily cascade right thru a component without its knowledge or consent. A component that looked right when it was placed inside the "shopping cart" component might look wrong when it is placed inside the "search" component.

The solution is to be selective about what properties are permitted to cascade into a component.


    .icon {
        all: initial;
        color: inherit;
        font-size: inherit;
        width: 1em;
        height: 1em;
        fill: currentcolor;
        display: inline-block;
    }
    <svg class="icon" ...>

In the above rule, the all declaration resets every property to its default value, stemming the cascade completely. The color and font-size declarations then selectively allow these properties to cascade in. There is no ambiguity about what is inherited and what is not. The width, height and fill declarations make use of the inherited values.


We have known for a long time that messing with global variables in JavaScript is a bad practice. We have known it long enough that it has more or less sunk in. The lesson is this:

Communicating via the global state weakens the modularity of the application, leading to brittleness and bugs.

It is time for us to apply this lesson when we style. The uncomfortable truth is that CSS is not a very good fit for application development. Until we have something better, we will have to rely on strict discipline to keep our styles truly modular.

See also


2022-01-02 I was wrong to say that components can not opt out of global CSS rules. The attachShadow method, present on most DOM elements, provides the element with its very own isolated DOM subtree. The subtree's elements are not targeted by global CSS rules, and any CSS rules defined within the subtree are properly confined. The subtree does, however, inherit cascading properties.