Most of the difficulty in writing HTML and CSS — or any language, for that matter — comes from defining objects and describing their relationships.
With CSS in particular, it’s hard to tell where one component stops and another begins. This is because the CSS namespace is essentially global. To address this problem, methodologies like BEM and SuitCSS provide conventions to denote the roles of individual components and their parts. Despite following a set of naming conventions, a large front-end codebase can be difficult to maintain over time.
Maintenance difficulties arise because HTML and CSS tend to be tightly coupled — if we make a change in our CSS there’s a broad set of corresponding changes that must then be propagated among a number of HTML templates; after all, every CSS selector reaches into the HTML to determine what elements it’s selecting. However, this doesn’t need to be the case.
Front-end systems consist of layers. Not two layers, as having a pair of languages might suggest, but several layers that exist both in code and in conventions and concepts. By identifying the appropriate layers to make certain abstractions, we can untangle HTML and CSS.
Let’s explore this process of decoupling through HTML/CSS interface patterns — how CSS exposes an interface that can be consumed by HTML instances of CSS components. In doing so, we’ll see how to improve the maintainability, modularity, and flexibility of a front-end system.
HTML/CSS interface patterns
Anti-pattern: Tightly coupled, element-based CSS
HTML defines the structure of a document, and we can use this structure to define an interface which CSS consumes to apply styles to elements within that structure. Sometimes called semantic CSS, this approach can be seen in the extreme of writing an HTML template, then writing styles for it without making any changes.
Here’s how that might look:
article > footer > ularticle > footer > ul > li
Here’s why this pattern falls short when building a large-scale front-end system:
- Ever-increasing specificity: If we decide to add another
<ul>to the article footer, perhaps a list of citations after the tags, this list now inherits all of the styles intended for the tags. We could work from there and contextually select
ul:last-childto distinguish the lists, but this builds toward increasingly non-robust selectors and unintended side effects. See: Harry Roberts’ article on cyclomatic complexity in CSS.
- Lack of reusability and composability: These tag styles can’t be reused elsewhere in the page or combined with other components without copying the whole block and creating a different selector. This leads to code duplication that complicates later changes.
- No isolatable components: There is no reduced case of a component that can be taken out of context from the page as a whole and documented in a pattern library.
- Not self-documenting: Inspecting the DOM, it’s unclear where in the project CSS styles are coming from. It’s also unclear how to locate existing components or organize new ones.
Pattern 1: Component-aware modular CSS
Maintainable CSS has flat specificity, can be reused on elements in a variety of contexts, and can be isolated and composed into different combinations of components. To achieve these ends, we’ll invert the responsibility of defining the interface so that CSS classes expose components as classes. Then, we opt an HTML element in to being a given component by applying a class to it.
HTML doesn’t have a one-to-one mapping of semantic elements to the components we need to implement. The component classes we expose are distinct from HTML’s semantic layer. However, we’ll aim to keep these components specific to what entity they represent — an article, a social media link, and so on.
To create the HTML/CSS interface at the layer of what a component represents, building from the previous example, we’ll define a component named
tagList with predictable class selectors:
.tagList for the list and
.tagList__item for each tag. (This follows a variation of the BEM naming convention.)
When we use this pattern, we create components that are exposed via CSS classes. These components are then consumed and implemented in the HTML. Unfortunately, this needs a bit more work to create a decoupled system.
Isolate design elements to their own layer within the CSS:
Although we can now easily combine components and move them around our pages, there’s an issue of code duplication if we need to implement identical designs between multiple different representation-oriented components exposed via our CSS.
To address this duplication of our design-oriented code, we can isolate and consume a design layer within our CSS. This could be done using preprocessor mixins:
Here, the design element is encapsulated by a mixin, but components representing different concerns are exposed as unique classes to be consumed within the HTML.
Encapsulate reusable components within the HTML view layer:
We’ve isolated the design concerns from the representational component concerns, but what if we need to change the markup of a component, not just styles that affect the already-written HTML structure? It wouldn’t be a good idea to copy and paste the HTML for a component across a number of templates. This can be easily solved by encapsulating each component in our view layer with its own template partial or macro:
Component// Renders a .tagList component...Component// Renders a .code component...
In this way, we’ve preemptively isolated each component from its design elements, whether or not those are shared across multiple components, and we’ve encapsulated each component so that it’s reusable and can be rendered into any view with a single source of truth for its markup.
Pattern 2: Design-aware modular CSS
Let’s back off from the idea of a component exposed via CSS. Once components are encapsulated in the view layer, we no longer need to expose a unified component from the CSS for each representational component. Instead, we can expose design elements directly.
The separation for this pattern will be that design concerns are contained in the CSS, and the implementation details of any component — both its semantic markup and consumption of design elements — are isolated in a component view.
One component might consume many design elements, and multiple components might consume similar or identical combinations of design elements.
This example contains the rendered HTML to illustrate what the design-oriented classes look like, rather than emphasizing one view library or another. In the implementation, assume we’ve kept the isolation of a component view responsible for rendering the list itself, and the view is being invoked in the page template to generate this output.
Pattern 3: Functional CSS
The easier it is to encapsulate components in the view layer, the less we need to worry about defining a view-aware interface via the CSS. Instead, CSS is only aware of design. If we take design constructs and break them into their purest pieces, we’re left with another extreme: functional (or immutable) CSS. An example library implementing this concept is Basscss.
I personally stop short of this point, but I understand its appeal. After all, CSS that is entirely predictable never needs to be changed at all. All of the necessary abstractions to decouple design for each type of component exist within component views.