Multiple Layers of Abstraction in Design Systems
Check out our previous post — Customization & Configuration in Design Systems — for more about how we define APIs for design systems.
The previous post on this topic explored two common paths that emerge in APIs — customization and configuration — each with its own pros and cons. Customization provides freedom and speed, while configuration offers cohesion and maintainability through the use of abstraction. The more abstractions we create in our APIs, the more we can control with ease — with less input from the user. The tradeoff is that the API becomes more opaque, and as a result, engineers may feel they have to wrestle with the API … or give up entirely in favor of local components.
Since that first post, the Encore team has gotten to know our design system customers (fellow Spotifiers) even better.
As a company, we now have hundreds of designers and thousands of engineers. We serve a diverse set of products ranging from the Spotify app we all know and love to the many other moving parts that make it possible: products for creators, advertisers, and internal employees. Some products are public facing, while many others work behind the scenes. Some areas of the organization are focused on design, and many more on building out functionality, which means that some teams don’t always have direct design support. Some features may push the envelope on UI, while others are concentrated on data and functionality.
We have a lot of customers who all have different use cases to fulfill with the design system. When trying to meet many different needs at once, a “this versus that” approach simply won’t do. We need aspects of both customization and configuration, working together.
The solution: provide multiple layers of abstraction at once.
Multiple layers of abstraction
We want to provide as much utility as we can out of the box while still offering the opportunity to modify some aspects of the component or to construct your own flows out of individual pieces. We currently have three touch points on the abstraction spectrum, each displayed in the card component example below, one of our first components released into production that explored this pattern.
Config: Passing in just data to props. Good for standard cases (most abstractions).
Slots: Passing in subcomponents to props. Good for small modifications — in this example, that’s a differently shaped image.
Custom: We provide just the base. Subcomponents and additional styles are managed by the customer. Good for complex cases — in this example, that’s a custom footer with some complex behavior (fewest abstractions).
Now that we can see these use cases visually, how would we implement them technically? While we do try and extend this pattern cross platform, this is what it looks like in the web stack. At Spotify, this consists of React and TypeScript, so in that context it looks something like this:
The biggest win for this pattern is that we are broadening the baseline of what a complex component looks like. This is classic configuration mindset: you give us the data, we will render it best. That only goes so far, though, as a design system’s default can never meet the needs of all customers at once.
This is where the slot level of abstraction comes in. Need a title with an icon in front of it? Need to add more complex behavior for routing or analytics? No problem. You can take the subcomponent we provide by default when a string is passed (we call this a default slot), modify it to your liking, and then pass it back into the card. No more adding props to the parent to modify some dense abstraction. Slots allow you direct access to the subcomponent’s API, allowing transparent interaction and preventing complexity from gathering in the parent component.
And of course, we generally want to allow custom implementations via composition as well, so if the need arises to do something more complex, customers will be able to include the individual pieces from Encore and get those visual benefits without being blocked by abstract aspects of the API.
While I am excited about this abstraction pattern and the range of customers it allows us to help, I am even more excited for the potential this setup has for the future.
The most universal benefit is this: Encore currently leans heavily toward customization, offering only pieces that the customers must piece together and keep up to date on their own. There are some things that everyone needs. Providing code examples covering these patterns only goes so far. We do a disservice by opting not to ship accessible focus flows or uncontrolled component state simply because there is a chance that someone will need to do something other than the default. Having layers of abstraction means that customization is not sacrificed when configuration is added. This can even lead to increased innovation, as it is faster to implement default features, freeing up time to think critically about what sets a feature apart from the rest and devoting time to making those aspects the best that they can be.
When a string is passed into a slot, we render a preset subcomponent, called the default slot. This provides a lot of utility for the config crowd, who are worried primarily about data. You pass in a string, and Encore turns that into a nice title element, for example. In the same way that we take care of a lot of generic UI at the design system level, consumers can now take care of more specialized UI and functionality within their specific projects at a repo level. Some things such as routing or analytics tracking may affect every card in the Spotify app — these are things that it would be nice to deal with the intricacies once, at a repo level, so on a daily basis, feature developers can pass in just data and be assured they are getting the correct baked-in functionality.
As an added benefit of specialized defaults, we get a clearer picture of how a local version of a component differs from the baseline. Does a specific feature need its own card component? This is bound to happen. Features come with their own complex details. Take, for example, a promotional card that may have specific button styles and analytics needs beyond a standard card. Now, because we have a broader baseline, there is less noise in these local implementations — everything that is still following the standard can pass in strings as normal, making it much clearer what is distinct about the private version of the component.
Custom slots aren’t only valuable to whoever wrote them. Utilizing analytics on our slot pattern, we can keep tabs on what props are overridden most often and what configurations they are overridden with. This can allow us to see the most common usage and keep an eye on how our default is tracking against customer usage. If a new variant has more usage than the default, that could be a good prompt to collaborate with those customers and potentially adopt a new default that keeps the system the best reflection of the product.
This is just the beginning of our journey — we now have a robust set of tools for meeting the needs of more customers with less friction and can define what works best in terms of giving options but still maintain a cohesive product. How this journey continues relies as much on our customers as it does the design system teams, and I am so excited to continue this collaborative process with the team.
If you’re excited about providing multiple layers of abstraction in APIs, there is a lot of industry inspiration that I encourage you to check out. This article on slope-intercept design by Jake Wharton gives a great breakdown of how this approach can benefit different types of users by lowering the barrier to entry while still enabling superusers. Nathan Curtis provided great guidance to “relinquish control, offer parts, and let implementers compose” in his article, Subcomponents. Also, from way back in 2011, in The Obvious, the Easy, and the Possible, Ben Fried outlines that how commonly something is done should affect how well surfaced it is in an API. This Invision talk with Josh Clark, Dan Mall, and Brad Frost gives guidance to “make the right thing to do the easiest thing to do” — this feeds into providing multiple layers of abstraction, with the goal that customers will have the easiest time reaching for default configurations. Thanks to the whole design systems community for inspiration and encouragement.