Although CSS has seen the light of the world more than 20 years ago, it's still surprisingly under-researched. Our websites keep breaking in spectacular ways: visual inconsistencies caused by seemingly minor changes can go undetected for days, negatively impacting the user experience (UX), inducing customer churn and frustration, which can lead to lost opportunities. All too often, CSS is not perceived as a critical resource requiring proactive and deliberate care, but as an annoying necessity to advertise the brand to the consumer.
CSS is a very simple language: its parsing and matching semantics are incredibly easy to understand and implement. Moreover, it is very forgiving and allows for graceful recovery: invalid or unsupported declarations are quietly ignored when the page is rendered, though carrying the risk for visual inconsistencies. Unfortunately, consumers may not be as forgiving.
This article is the first in a series inquiring into the cognitive complexity of CSS, the aim of which is to deduce quantifiable measures to improve the developer experience (DX), and thus productivity, regardless of methodologies or frameworks.
In order to better understand what complexity is, we distinguish two orthogonal concepts:
Computational complexity quantifies the required resources (space and time) to solve an algorithmic problem, i.e., the time needed by a computer to find a solution to a problem. An increase in computational complexity may negatively impact performance, and thus UX.
Cognitive complexity is a concept that tries to quantify the difficulty for a developer to read and understand a unit of code, i.e., the time needed by a human to find a solution to a problem. An increase in cognitive complexity may negatively impact productivity, and thus DX.
While computational complexity quantifies the (maximum) number of steps necessary to solve a problem, cognitive complexity aims at understanding complexity in a more intuitive, human sense, which is why it is heavily context-dependent and much harder to quantify. Surprisingly, an increase in computational complexity doesn't imply an increase in cognitive complexity, and vice versa – both are not necessarily correlated. A white paper by Ann Campbell goes at lengths to define cognitive complexity as a metric for imperative programming languages. It's quite astonishing that there seems to be no definition or discussion in the context of CSS, although intuitively we know: it exists.
Let's consider a simple example: suppose we want to change the color
of
an element. First, we need to identify all relevant instances of the element and
decide how to target it using a selector. Second, we need to decide whether to
add a new rule (i.e. selector) or reuse an existing one. If the element already
has a class selector attached to all instances, it is very tempting to
append a new declaration to the existing rule and consider the job done.
But is it?
Unfortunately, the reality is that the actual work starts here: we have to
embark on a search for potential unintended changes on other pages with elements
the class selector might also match. Moreover, there might be contexts where
other selectors with higher precedence overwrite our new declaration. Also,
since color
is an inherited property, the change might accidentally propagate
to child elements. In some cases, there's no selector to reuse, but we might
also not be allowed to touch the HTML to attach a new class to the element.
We would have to find other ways to precisely select the subset of relevant
instances with a carefully crafted, more complex selector. This will
introduce assumptions about the structure of the HTML, which might be subject
to change or vary across pages, the like of which there might be hundreds or
thousands.
Thus, the cognitive complexity of CSS is hidden in its execution context, consisting of the HTML it is written for and the environment within which it runs (i.e. the browser). The rendering of a page is the critical moment when unintended changes may manifest into problems.
Even the tiniest stylistic changes require a thorough understanding of the current and, ideally, also possible future HTML. As codebases have the inherent property to grow over time, it's becoming consistently harder to predict the impact of a seemingly minor change due to the inevitable combinatorial explosion stemming from a combination of the following factors:
- Document variance
- Selector precedence
- Property inheritance
- Conditional rules
- Browser support
- Browser bugs
Writing future-proof and stable CSS in the presence of volatile HTML without losing control over the codebase is exceptionally hard. Complexity should be managed strategically from the start, which is often forgotten or not considered a priority, eventually leading to the problem of uncontrolled growth and the all too common solution: starting from scratch.
Regardless of the technological stack, deliberately managing complexity should be considered an essential aspect of every mature development process to ensure that the temptation to start from scratch and throw away months or years of work doesn't arise quite as often. A carefully planned and well-executed architecture is essential to manage complexity proactively, which is true for CSS as it is for every other language.
Organization systems (fig 1.) have increased in popularity over the last years, especially with large and fragmented development teams, as they give architectural guidance, ease collaboration, and improve on reusability. They are split into two categories:
-
component-based (element-centric) systems organize declarations in rules with selectors targeting specific HTML elements. The most popular contenders are BEM and SMACSS, both of which are examples of OOCSS, an adaption of the object-oriented approach known from imperative programming languages, and are rather guidelines than frameworks.
-
utility-based (selector-centric) systems move each declaration into a single-purpose rule (i.e. a class selector) with a clear name based on its visual effect; elements are styled by combining multiple of those single-purpose rules. Most popular contenders are AtomicCSS and Tailwind, which are rather frameworks than guidelines due to the necessity of some tooling.
While both types of organizational systems foster reuse through modularity, they can only partly reduce the risk of unintended changes that lead to the aforementioned combinatorial explosion. Also, weird situations may arise when those systems are dogmatically applied, which is often only solvable by breaking some of the rules they impose, e.g., when styling body copy. However, improving on modularization is an essential step towards better maintainability and predictability of changes. To learn more about the advantages and disadvantages of different organization systems, see the blog articles here and here.
Module systems (fig 2.) help reduce the risk of unintended changes by scoping styles to components – i.e., dedicated subtrees of the document – and come in two different flavors. While CSS Modules can't solve the problem of accidental propagation of inherited properties to child components, as the resulting rules live in the same context, Web Components provide clear isolation boundaries. As good as it may sound, isolation boundaries raise the need for redundant definitions of common styles like fonts or colors, which are typically cascaded down from the root and only overwritten selectively. These redundancies may lead to inconsistencies due to the need for duplication or demand sophisticated build pipelines to ensure coherent definitions. Also, browser compatibility is still a thing.
Although the introduction of modularization and isolation boundaries can be considered a critical success factor for scaling CSS in medium to large development teams, they come with caveats. Thus, they should not be perceived as all-in-one solutions. Furthermore, introducing these techniques retroactively (e.g. into a legacy project) can be very challenging and time-consuming.
Besides complexity management, it is sometimes necessary to analyze the status quo of a codebase to get a feel for the degree of maintainability or assess the general need for refactoring. This procedure is called complexity analysis and is often performed reactively.
Static analysis is the process of analyzing one or more stylesheets using a
defined set of criteria, e.g., number of #id
selectors, number of !important
declarations, number of declarations per rule, number of selectors per rule,
etc., to find disregarded best practices or code smells which could (already) be
causing problems. Additionally, derived measures can be calculated, which may
shed some light on the statistical properties of the underlying codebase like
minima, maxima, and average values. Although it's quite interesting to see that
the average selector length might be 1.6180 or there are 50 almost identical
shades of grey, it's quite hard to derive actionable measures beyond
deduplication to improve on brand consistency.
Specificity graphs (fig 3.) help to understand the precedence relations of selectors defined in all stylesheets that apply to a given page by plotting each selector's location against its specificity. The general idea is that a well-defined selector ordering (i.e. precedence) can be considered beneficial for retaining the control over a codebase and not to descend into specificity wars. An upward-trending graph helps to normalize the relationship between location and specificity, which improves the locality of changes and thus their predictability. However, beyond detecting spikes and trying to flatten the graph, it is, again, quite a challenge to deduce actionable measures from such a visualization alone. Moreover, modern development practices fostering the use of CSS pre- and post-processor (e.g. SASS and PostCSS) tend to produce inherently spiky graphs, as those tools usually concatenate many separate files into a single bundle.
You can perform both analyses for your website on cssstats.com.
Though CSS is easy to learn, it is hard to master. While the modern web has come a long way, our tooling hasn't caught up yet. Aside from complexity management and analysis, there's an urgent need for a deeper understanding of how the CSS and HTML we write, evolve together.
How big is the impact of a change? At this time, this question can only be answered by knowing the ins and outs of the underlying codebase and filling knowledge gaps through meticulous research before deploying a change to production, regardless of the nature of the change. Even today, a developer has to be familiar with the codebase, weigh all of the factors that lead to the combinatorial explosion, and decide whether a change can be made with confidence.
As for imperative programming languages, automated tests can help to improve confidence. Unfortunately, in the context of CSS, they are still too inefficient and impractical. Screenshot testing, which is the de-facto standard of the industry, can catch regressions introducing visual inconsistencies. Put into practice, it is often flaky, expensive to operate and scale, and requires permanent maintenance. Furthermore, most screenshot-based solutions can only be used with static content (e.g. pattern libraries), as they are not designed to adapt to changing content, and are not capable of identifying cross-browser visual inconsistencies.
So, you ask, what should you do? We don't know yet. However, as we now have mapped out different directions to explore the cognitive complexity of CSS, we're excited to venture into uncharted territories where currently there're only to be dragons.
This presentation accompanies the article: https://squidfunk.github.io/talks/css-cognitive-complexity/#/