Decoupling HTML/CSS front-ends with domain and UI components

By identifying and encapsulating specific concerns, we can make front-end component systems composable, reusable, and resilient to future changes. We’ll explore CSS patterns and principles, using React and TypeScript to draw boundaries between business or domain components and composable UI components.

Different types of components have different responsibilities

Most of the challenge in writing front-end systems comes from defining clear component responsibilities and describing their relationships. Legacy front-end systems are often burdened by tight coupling and poor encapsulation of UI patterns.

There’s a problematic aspect of CSS, which is that it can be hard to tell where one component stops and another begins. This is because the CSS namespace is global. To address this, methodologies like BEM and SuitCSS provide conventions to communicate and document the roles of individual components and their subcompontents. However, despite following a set of naming conventions, a large front-end codebase can be difficult to maintain over time.

Let’s examine how we can approach a component-based system — such as a React application — with a strategy to cleanly separate domain and UI concerns, while communicating design intent and facilitating extensibility of the system.

Legacy CSS

Anti-pattern: Tightly coupled, element-based CSS

In a tightly-coupled approach, HTML elements define the structure of a document, and we use this structure as the public interface of the document. CSS hooks right into that document structure via selectors, generally child or descendant selectors. 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 to the HTML to accommodate the CSS. The result is the CSS is tightly coupled to the structure of the document.

Here’s how that might look:

article > footer > ul {
  border: 1px solid #ddd;
  padding: 10px;
}
 
article > footer > ul > li {
  display: inline-block;
  border-radius: 4px;
  color: #fff;
  background: #000;
}
<article>
  <footer>
    <ul>
      <li>Web Development</li>
      <li>Front-End</li>
      <li>HTML/CSS</li>
    </ul>
  </footer>
</article>

Why did people ever code this way? It comes down to the line between websites and web applications. For a simple blog, this approach might be entirely adequate. It’s fast, reliable, and understandable in that context. However, for CMS-driven websites, and especially for larger, more interactive web applications, this approach falls apart quickly.

Here’s why this pattern falls short when building a large-scale web application:

  • Lack of reusability and composability: These element-oriented 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.
  • 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:first-child and ul:last-child to distinguish the lists, but this builds toward increasingly non-robust selectors and unintended side effects. See: Harry Roberts’ article on cyclomatic complexity in CSS.
  • 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.
  • Unexpected side effects: Accidentally consuming styles meant for somewhere else is common if we cooincidentally share a similar DOM structure. Whether this results in a desired style or an unpleasant side effect, this consumption creates brittle interconnections between what were intended as separate components.
  • 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. Inferring the authors intent from this sort of code is difficult.

As this illustrates, relying on element structures in the DOM as the public interface for CSS to consume is not reliable. Instead, we’ll need to look for ways to create a clearer contract between a component’s template and the CSS that applies to it.

Pattern 1: Domain components

With this pattern, we’ll aim to keep styles directly connected to the domain-specific entities they represent — an article, a social media link, and so forth. These are the main level of business components we’re likely to implement in a lightly-architected web applicaiton.

Maintainable CSS has flat specificity (preventing unnecessary specificity conflicts), can be reused on elements in a variety of contexts, and can be cleanly isolated and composed into different combinations of components. To achieve these ideals, we’ll invert the responsibility of defining an interface so that CSS exposes a public interface of classes that HTML elements consume.

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.)

We’ll also fully encapsulate the component’s template, scoping styles to the specific component and avoiding applying styles across component boundaries. Class-based selectors ensure we don’t unintentionally leak styles into a child component. If a component defines styles, those should be specific to it, and should not result in side effects to adjacent or child components. This is the rule of ensuring composable components: if we can see a piece of HTML in a component’s render function, we can style it; if we can’t, we need to be sure not to leak styles onto it.

.TagList {
  border: 1px solid #ddd;
  padding: 10px;
}
 
.TagList__item {
  display: inline-block;
  border-radius: 4px;
  color: #fff;
  background: #000;
}
type Props = {
  tags: string[];
};
 
export const TagList = ({ tags }: Props) => (
  <ul className="TagList">
    {tags.map(tag => (
      <li className="TagList__item">{tag}</li>
    ))}
  </ul>
);

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 more reusability within the system. Although we can now easily combine components and move them around our application, there’s an issue of code duplication if we need to implement identical designs between multiple different domain or business components.

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:

@mixin borderedBox {
  border: 1px solid #ddd;
  padding: 10px;
}
 
@mixin backedTxt {
  display: inline-block;
  border-radius: 4px;
  color: #fff;
  background: #000;
  text-transform: uppercase;
}
 
.TagList {
  @include borderedBox;
}
 
.TagList__item {
  @include backedTxt;
}
 
.Code {
  @include backedTxt;
}

Here, the design element is encapsulated by a SCSS mixin, but components representing different domain concerns are present at the interface of HTML and CSS. With the combination of CSS encapsulation of design concerns and a clear public interface between domain component templates and their styles, this pattern puts us on a reasonable path for long term maintenance.

Pattern 2: UI components

Taking a step back from domain components and keeping design abstractions encapsulated within the CSS, what if we instead create design-focused UI components that domain components can consume?

One domain component might consume many UI components, and multiple domain components might consume similar or identical combinations of UI components. Many details described in our first approach still apply, but by creating a component just for the UI concern, we start to reach results like the following, where the implementation details of the design language are known to a set of UI components, and all that matters for domain components is consuming the UI:

.Box {
  border-radius: 4px;
}
 
.Box--lightGrayBorder {
  border: 1px solid #ddd;
}
 
.Box--inlineBlockDisplay {
  display: inline-block;
}
 
.Box--inverseVariant {
  color: #fff;
  background: #000;
}
 
.Box--tightPadding {
  padding: 10px;
}
 
.Box--flushPadding {
  padding: 10px;
}
type Props = {
  display: 'block' | 'inlineBlock';
  border: 'none' | 'lightGray';
  variant: 'default' | 'inverse';
  padding: 'flush' | 'tight';
};
 
export const Box = ({
  display = 'block',
  border = 'none',
  variant = 'default',
  padding = 'none',
}Props) => (
  <div
    className={[
      `Box`,
      `Box--${display}Display`,
      `Box--${border}Border`,
      `Box--${variant}Variant`,
      `Box--${padding}Padding`,
    ].join(' ')}
  >
    {children}
  </div>
);
type Props = {
  tags: string[];
};
 
export const TagList = ({ tags }: Props) => (
  <Box padding="tight" border="lightGray">
    <ul>
      {tags.map(tag => (
        <li>
          <Box variant="inverse" padding="flush" display="inlineBlock">
            {tag}
          </Box>
        </li>
      ))}
    </ul>
  </Box>
);

What this accomplishes is separating UI concerns from domain concerns, and ensuring that the same UI can be used to represent multiple different domain concepts. For instance, a card only cares that it’s the UI of a card — possibly with some stylistic variations for different use cases — but not that it’s a profile card. By composing different UI components within domain components, we can create interesting and effective representations of the domain concepts that build on patterns rather than needing to assume each starts as a unique design entity.

Note that this example overloads the Box component a bit too much, and in a more fleshed out UI system, we’d likely break down some of these responsibilities. I’ve written a series on CSS UI Patterns that’s a good starting point for recognizing common UI patterns that can easily be adapted to your framework of choice.

Pattern 3: Functional CSS

The easier it is to encapsulate components in the view layer, the less we need to worry about defining a set of comprehensive UI components up front. Instead, CSS is only aware of low-level design primitives. If we take design constructs and break them into their purest pieces, we’re left with an 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. All we change is how a component’s HTML consumes a set of unchanging primitives.

type Props = {
  tags: string[];
};
 
export const TagList = ({ tags }: Props) => (
  <ul className="padding-10 border-1-solid-ddd">
    {tags.map(tag => (
      <li className="display-inline-block color-fff background-000 border-radius-4">{tag}</li>
    ))}
  </ul>
);

First published in 2016; revised and updated in 2019.