Designing Product UI Systems
(Short preface/definition: âUI systemsââcollections of functional UI components and their stylistic appearancesâare referred to as âdesign systemsâ in this article. Where necessary, functionality and appearance are discussed individually, but know that âdesign systemâ here encompasses both appearance and function.)
At any scale, design systems are important in product development. At their simplest, they help reduce redundancies and inconsistencies in design and engineering implementations. At their most successful, they enable a universal language for designers, engineers, PMs, and everyone in between to efficiently solve problems.
One common problem design systems encounter is often resolved with an unfortunate compromise; how do you prevent people from diverging from the system, while still being flexible enough to allow creativity and exploration beyond the constraints of the system? By making a design system too flexible, you risk undoing the hard work of reducing inconsistencies. By making one too rigid, its users are discouraged from using it and retreat to their own solutions.
Composition versus inheritance
The issue of an inflexible design system is usually caused by the most sensible method of building a design system; by creating components that address specific requirements.
We can start with a simple component found in most design systemsâa button. You might design the appearance of a button in your system with a certain font size, text and background color, and a border. This button could also have 2â3 different visual (and functional) states, so you add some variant color shades for the states. By styling different states, the complexity of the component increases, and its flexibility decreases. To counter the lack of flexibility, you might then create several other buttons for different use casesâprimary, secondary, tertiary, and âspecialâ buttons. These extra buttons have multiple states themselves.
Then comes the question of contextâwhat happens when we need the buttons to be bigger on bigger screens? Or in an environment with inverted colors?
The buttons weâve designed for our system suffer from overzealous inheritanceâassumptions of the context of the component, and potentially dangerous overrides as workarounds.
Think of it a little like trying to build a house out of Lego, but instead of bricks, youâre given a single block of plastic, perfectly carved into the shape of a pre-built house. Great! Job done. But if you want a different house? Youâre going to need a whole new house-shaped block. Wouldnât it be nicer to have bricks that can be detached and reassembled as required?
Suppose instead that we identify the primitive properties of the buttons we have designed. We take the background colors, borders, spacing, and font sizes, and we make those our components. Then, for any variant of button, we simply compose the comprising parts we want. Thereâs then nothing stopping us from creating a template button that has a set of default primitive styles, but one that allows us to easily override those styles within the constraints of the other primitive styles we have defined. Rather than creating four or more distinct buttons, we create just a mould and a palette; the only tools we need for numerous possibilities.
We can go further. We take those primitive styles, and recognize that we see themâor styles extremely close to themâin other components. We see the same borders in our date picker component; the same background colors in our headers; the same spacing in our cards. Without having to rebuild each of these components, we have the pieces we need to assemble them on a need-by-need basis. By breaking designed components into their atomic, stylistic parts, we may be surprised how little we need to build a complex system.
Separating Form and Function
Note: the code henceforth is simply for demonstrative purposes. Also, you might be thinking âCode? I thought this was a Design Systems post,â and rightly so. Itâs become a natural tendency for me to anchor my ideas in engineering principles. Bear with meâhopefully youâll make it through with some understanding of what Iâm getting at.
Some of you may at this point be thinking that this method of composing components only works at a styling level, and not at a functional level. And youâre rightâbut bear with me. Our original, problematic, inheritance-constrained way of building components for a design system doesnât have this issue, since the function of the component is a part of its design. By decomposing the style properties of the component, youâre left with a functional component that has no appearance, or an appearance that almost always requires style overrides.
So how exactly does this work in practice? For the sake of demonstration, weâll consider web components. To start, React/JS components wouldnât have corresponding CSS files. Their styles would be set by a dictionary/enumeration, perhaps something like this:
class ButtonComponent {
render() {
const style: Style = {
...Button.Primary
}
return (
<button style={style} onClick=... />
)
}
}
You can consider this example as one of those âtemplatesâ I mentioned earlier. A
mould; a starting point for any button-like component, with sensible default
styles. Our Button.Primary
style may expand to this:
const Button = {
"Primary": {
"default": {
...Controls.Base // font sizes, a11y may be set in here
backgroundColor: Color.Blue,
color: Color.White,
},
"hover": {
backgroundColor: Color.DarkBlue
},
"active": {
backgroundColor: Color.XDarkBlue
}
}
}
During compilation, inline style properties would not appear in the output. Instead, inline styles would be translated into global unique classnames, generating a class for each property-value pair:
<!-- Actual output could be much more terse -->
<button class="bgc_Color_Blue hvr_bgc_Color_DarkBlue ..." />
Generating class names for every property-value pair may seem like a lot of output, but with a reasonable and known set of constrained values (e.g. only pixel units in multiples of 4, and only between 0 and 80), you would have predictable and limited output.
Overriding stylesâwithin the constraints of the systemâwould be easy:
class MyCustomButton extends ButtonComponent {
render() {
const style: Style = {
...Button.Primary,
width: Width.FullWidth,
fontSize: FontSize.LargeControl
}
return (
...
)
}
}
Unlike CSS, expressing styles through objects like this means that youâre decomposing and recomposing/replacing styles rather than overriding them with crude CSS selectors or specificity hacks.
And of course, we can ensure deviation from standards is prohibited through âtypesâ:
type Style = {
width?: oneOf(...Width.values()),
...
}
type ControlStyle = {
...Style,
// additional constraints may be put in place for
// components of certain types, e.g. a control/input
// may never have .pointerEvents overridden
pointerEvents: null|void,
}
Given my limited ability to implement and test a design system like this at scale, there are undoubtedly flaws in its approach and hurdles that will be glaringly obvious to many folks. But we can approach these weak points as opportunities to learn in order to create a process and system that can become an industry standard.
This post and design system methodology takes inspiration from Appleâs Cocoa APIs, Brent Jacksonâs CXS and Rebass, Adam Morseâs Tachyons, and my own work on design systems and UI frameworks for Dropbox.