Nick Lemmon

Inversion of control in design systems

January 23, 2025

The promise of design systems

UI component libraries and design systems enable brand consistency at scale. By leveraging a design system, large organizations can ship consistently branded digital experiences without the need to re-build repetitive UI elements across multiple applications simultaneously.

Designers and engineers on a design system team may view a component library as a means of establishing visual and logical control over disparate teams and processes, narrowing the range of possibilities that can be produced across a large digital footprint.

For example, the component may include some opinions about where icons can be placed. In our initial draft, we built a button component that accepts an icon prop:

A simple problem: a button with an icon

import { Button } from '@company/ui'

function MyApp() {
  return <Button icon="plus-circle">Click me</Button>
}

Designers and engineers on a design system team may view a component library as a means of establishing visual and logical control over disparate teams and processes, narrowing the range of possibilities that can be produced across a large digital footprint.

Ta-dah! In this example, the Button handles the icon via a unique identifier, rendering it on the right hand side of the element only. Now we can require teams within our company to only position icons on the right and make sure we keep our buttons visually consistent across applications.

Fix #1

Unfortunately, we immediately get a request to support icons on the left — and the requesting team has a very strong argument for doing so that we never could have anticipated when we first started. How can we handle this without publishing a breaking change to our component? Maybe the better original component API would have looked something like this:

import { Button } from '@company/ui'

function MyApp() {
  // `iconPosition` can be "right" or "left", defaulting to "right" when nothing is set
  return (
    <Button icon="plus-circle" iconPosition="left">
      Click me
    </Button>
  )
}

Ta-dah! We did it. Now we have solved our problem and we can ride in to the sunset truly and confidently victorious. We do have two separate props that are tightly coupled, but at least they both start with the name icon so that shouldn't be too painful...

Fix #2 (not again)

Once again we receive a request from a team building an application — and they really need two icons at once, one icon on each side simultaneously. Well, this will be difficult to support with our existing component API but let's roll up our sleeves and give it a shot.

import { Button } from '@company/ui'

function MyApp() {
  return (
    <>
      {/* We still need to support our old API, so let's leave this as an example of what that looks like */}
      <Button icon="plus-circle" iconPosition="left">
        Click me
      </Button>
      {/* We can also support this newer approach (definitely bulletproof this time!) */}
      <Button rightIcon="plus-circle" leftIcon="checkmark">
        Click me
      </Button>
    </>
  )
}

I'm not sure what would happen if an engineer tried to use iconPosition and leftIcon at the same time, but surely no one would do something so foolish, right?

Fix #3 (It won't stop)

After declaring victory, we ride in to the sunset. At least until another team requests our help with the Button component. This time they aren't too concerned with iconPosition, but they do want to change the color of the icon somehow — and ideally they could use a design token to do so.

Well, that shouldn't be too hard (though we do have to support all of our existing component API surface when we make this change):

import { Button } from '@company/ui'

function MyApp() {
  return (
    <>
      {/* Well, `iconColor` will work here but we definitely can't have two different colors at once */}
      <Button icon="plus-circle" iconPosition="left" iconColor="fg.action">
        Click me
      </Button>
      {/* Aha! We *can* support two different colors with this API though */}
      <Button
        rightIcon="plus-circle"
        leftIcon="checkmark"
        rightIconColor="fg.action"
        leftIconColor="fg.success"
      >
        Click me
      </Button>
    </>
  )
}

What started with good intentions (control over design) has quickly devolved in to a mess for the engineering team, all without any material benefit with regards to design consistency and standardization.

Well, we made the consuming team happy but this is getting really gnarly and hard to maintain. In order to support icons in two positions, each with distinct imagery and color, we now have 7 props we need to maintain — and nearly all of them are tightly coupled with one another, meaning each time we need to change 1 we put the other 6 at risk of a break. Not good.

Not to mention, the design team's goal of controlling the visual positioning of icons within buttons has now been lost. Now we have lost consistency and we have a highly complex component simultaneously. What a mess.

What started with good intentions (control over design) has quickly devolved in to a mess for the engineering team, all without any material benefit with regards to design consistency and standardization.

Enter component composition and inversion of control

Looking from the end result, it's easy to see how a component API like this can naturally evolve over time and end in a unenviable place. A rather simple component that accepts an icon is now extremely complex and brittle to maintain. If we could hop in a time machine, how could we have handled this better?

Children/slots and the compound component

Instead of using string props, it's time to use children (in some other frameworks this might be content projection or a slot) to make the component surface area much smaller while keeping everyone happy (especially the design system engineering team):

import { Button, ButtonIcon } from '@company/ui'

function MyApp() {
  return (
    <>
      {/* To reposition the icon, we just put it first in the DOM order just like regular HTML */}
      <Button>
        <ButtonIcon icon="plus-circled" />
        Click me
      </Button>
      {/* And here is the same icon on the right */}
      <Button>
        Click me
        <ButtonIcon icon="plus-circled" />
      </Button>
      {/* And now one icon on each side */}
      <Button>
        <ButtonIcon icon="success" />
        Click me
        <ButtonIcon icon="plus-circled" />
      </Button>
      {/* And now each icon has its own color */}
      <Button>
        <ButtonIcon icon="success" color="fg.success" />
        Click me
        <ButtonIcon icon="plus-circled" color="fg.action" />
      </Button>
    </>
  )
}

Success! We have accomplished a few things with this approach:

  1. We have decoupled props — the ButtonIcon component has its own props that control its appearance, but they are distinct from the parent Button
  2. Teams can implement icons in whichever order they choose in an easy-to-understand way, no component API documentation required
  3. The Button component is much less likely to break accidentally when its internal structure changes compared to the old approach
  4. Teams can even use conditional logic within a button instance more easily, using JSX to conditionally add and remove icons based on conditions relevant to their users

Empowerment over top-down control

By empowering application teams to better serve the needs of their users, design system teams can build components with a much better developer experience all while reducing the risk of regression defects in the future. Teams may build wild and crazy things using their newfound power of component composition — and at the end of the day that's OK. Application teams are almost always closer to their users and the needs of the business and giving application teams the keys to their own destiny is the only way to effectively build a design system at scale

Learn more

Inversion of control, component composition, and compound components are not new ideas, yet many design systems still fail to empower their application teams to build high quality digital products by inverting (giving up) control.

If you are having trouble taking the component composition high road (or convincing others to do the same), read through some helpful articles on the subject from industry experts to learn more about the how of the approach:

Nick Lemmon

About Me

I’m a frontend engineering manager working for Truist Financial currently based in in Columbia, Maryland.

I’m driven to spearhead, design, and build accessible design systems and web applications with a great underlying developer experience in mind.