On Declarative Styling


The enemy of art is the absence of limitations.

— Orson Welles

There is a problem at the heart of CSS. It’s not the cascade, or specificity, or inconsistencies between rendering engines — though these things can be annoying. No, it’s much simpler than that: the problem is that we can write too much of it.

I am not talking about append-only stylesheets (though these too cause their issues). Even if we’re extremely disciplined about refactoring our CSS, and we only add new rules when absolutely needed, something is still wrong. The problem is the flexibility of the language itself. There are nearly unlimited valid values that a padding declaration can take, and while extremely freeing this also introduces surface area for inconsistencies in our designs. And consistency is key to good design! It reduces the end user’s cognitive load, it (generally) looks better, and it minimizes the workload for designers & developers to boot.

Artificially limiting the number of values that we can use in declarations is key to avoiding these inconsistencies. We want a declaration like padding to act a little more like float; we should only be able to set a value that we’ve defined in our governing system. There are many techniques and technologies that can help us accomplish this (or at least get us close). I call the overarching concept that these tools encompass “declarative styling”.

Defining “Declarative”

This term — declarative styling — is derived from the computer science concept declarative programming. It means we want to tell the computer the rules for drawing our interface, and let it follow those rules for us. We no longer want to write padding: 1rem, we want to write something like padding: 3 and have the computer replace the 3 with the 3rd value in our spacing scale.

This accomplishes several things for us. It ensures consistency across our design by allowing us to use a reference rather than a specific value. It also reduces the cognitive load for stakeholders by providing a common language to communicate in. These factors (among others) can make designing and iterating faster, and all but eliminate the inherent friction in designer-developer handoff.

Design Tokens

Those familiar with the concept of design tokens may find a lot of conceptual overlap here. Design tokens are an essential part of declarative styling: they are how we define our custom subset of styling options. If a rule in our stylesheet declares a background-color, that swatch should be found in our tokens.

There are many techniques for storing and parsing design tokens. I’m partial to the JSON-based System UI theme specification, which organizes our tokens into a variety of scales. Several of the tools discussed below rely on this or a similar technique, but the concept remains the same: there should be a source of truth for these values, and it should not be the CSS rule itself.

Techniques

Much like there are a multitude of ways to store our tokens, there are many, many ways to apply them to our styles.

CSS Variables

The most commonly-used solution, and one you’re likely familiar with, is to use variables whenever possible. Although Sass and LESS have had variables since their inception, CSS now has native variable support with the custom properties specification.

.box {
  padding: var(--spacing-3);
}

Unlike the variables of preprocessors like Sass and LESS, custom properties can take full advantage of the cascade. This means we can create fully themeable component styles natively.

.btn {
  /* First we define our variables in the base component style */
  --button-padding-y: var(--spacing-2);
  --button-padding-x: var(--spacing-3);
  /* Then we apply our variables to our declarations */
  padding: var(--button-padding-y) var(--button-padding-x);
}

/* By updating our variables, the styles will change automatically */
.btn--large {
  --button-padding-y: var(--spacing-3);
  --button-padding-x: var(--spacing-4);
}

To take full advantage of this system, we need to convert our tokens to custom properties in the :root selector. The easiest way to do this is to copy and paste the values by hand, though there are tools to automate the process.

Of course, no method is without a downside. In this case, the biggest flaw is the same as its biggest draw: the low barrier to entry. There are no guardrails to stop you from writing padding: 24px instead of using your variable. It takes a lot of discipline to not deviate from the system, and any time you’re writing new CSS is an opportunity to create a discrepancy. But combined with a strict code review process, this can be a powerful way of enforcing consistency while creating a themeable system.

Atomic CSS

Atomic CSS (aka Functional CSS, aka Utility-first CSS) libraries like BassCSS, Tachyons, and Tailwind CSS are declarative by definition. Classes like p3 automatically follow our padding rule from above: we’re telling the engine to apply equal padding to all sides (the p) using the third step from our spacing scale (the 3).

<div class="p3">Hi</div>

Customizing BassCSS and Tachyons can be tough, but it is possible. Tailwind, on the other hand, is fully customizable by default. The Tailwind configuration system is a plain JS object exported from a Node script. A major benefit to this approach is that we can read our tokens from a JSON or YAML file, and apply them to our custom config with a few lines of code.

I’m on the record as being a big, big fan of atomic CSS. But I’m not blind to the disadvantages. The learning curve can be quite steep; not only do we need to internalize the naming scheme, but we also need to rethink how we apply our CSS. Because we also need to apply a fair amount of classnames to our components, I also tend to recommend this approach only for very simple projects or for projects that have a powerful templating system. Applying atomic CSS classes to a React component or Pug mixin makes applying the classnames to our elements much more palatable.

Styled System

The advent of CSS-in-JS has presented us with a unique opportunity: by taking advantage of an expressive, fully-featured programming language, we can create abstractions on top of CSS that would otherwise be impossible. For example, by taking the lessons learned from atomic CSS and applying them to a JSX-based CSS-in-JS system, Brent Jackson has created several purpose-built declarative styling libraries.

The first of these libraries, the foundation upon which the others are built, is called Styled System. The library provides a consistent interface to refer to your design tokens when defining or consuming a component. The true genius of Styled System is how it handles responsive styles. By default, Styled System props accept a string, which the library parses and converts to a value represented by a design token. If the token is not found, the literal value is passed to the underlying CSS-in-JS library. However, by passing a prop an array, it will apply the 0-index value to the component by default, the 1-index value to the component at the first breakpoint, and so on and so forth.

import styled from 'styled-components'
import { space, color } from 'styled-system'

const Box = styled.div`
  ${space}
  ${color}
`

const MyStyledSystemComponent = props => (
  <Box p={[2, 3]} color="primary" {...props}>
    Hi
  </Box>
)

This approach allows us to not only create components that are not only consistent with our system, but are extremely portable as well. A rule of thumb I try to follow when styling a component is to only define surrounding vertical margin when the component is actually consumed in a view. By spreading our props to a root component defined with Styled System, it becomes trivial to follow this rule.

const MyView = () => (
  <main>
    <header>...</header>

    <MyStyledSystemComponent my="4" />

    <footer>...</footer>
  </main>
)

Because Styled System (when used in React) reads from the theme put into context from your CSS-in-JS library of choice’s ThemeProvider, creating themeable components is a breeze. However, attaching space, color, and other token references to all your components can be quite annoying. Luckily, Mr. Jackson has also created a library of primitive components built on top of Styled System called Rebass. These components operate like the Box component we utilized inside of MyStyledSystemComponent.

Most recently, Brent has released Theme UI, a further iteration of this idea. Theme UI exposes an sx prop on JSX components that allows us to reference our tokens, provides primitive components to use as a foundation, handles styling of markdown content with MDX, and much, much more. It’s an exciting evolution of the approach, and one I intend to explore more fully in the future.

The Styled System approach, much like the others, comes with its fair share of downsides. For starters, it shares the same learning curve problem as atomic CSS. It can be somewhat cumbersome to set up, particularly when not using Rebass or Theme UI’s primitive components. And while it can technically work with any framework that supports JSX, React is the only true first-class Styled System citizen. But, when given a choice, it is still my preferred method for defining and consuming styles.

A Vision of the Future

The web has always been an extremely flexible and expressive platform. This has lead to some amazing creative applications of technology, new media experiences, and beautiful interactions. It has also created plenty of footgun opportunities. By placing self-imposed constraints on the very fabric of the web, we get to remove the more self-sabotaging aspects of designing for the platform. Rather than limiting our creativity, these constraints provide guardrails for us to explore the limits of our designs while providing a consistent and visually harmonious interface to our users.

The declarative styling methodology will continue to evolve over time. As design tokens become more prevalent, design software will add first-class support for them. More libraries and methods will evolve for applying them to our products, both on the web and beyond. As we solve the problem at the heart of CSS, the language we use to communicate between ideation and implementation will meld into one. Perhaps our tools will as well.