🧩 The Primitives and Core Pattern for Building Design Systems (#77)
One of the main challenges I've faced working on design systems for the past 6 years has been walking the fine line between strictness and flexibility.
I've been on teams that created systems with endless customization options. I've also been on teams that didn't even expose a className
prop and didn't allow any customization at all. I don't think either design system reached its full potential.
As design system maintainers, we need to strike a perfect balance between strictness and flexibility. I think devs should be able to drop the vast majority of design system components directly into the product without any additional customization. But we need to acknowledge that there will always be valid use cases that aren't support by the design system.
Progressive Disclosure of Complexity
Design is always evolving. The designs a company is using right now will change vastly over the next 5 years. The design system needs to make that possible while still maintaining the company's current brand identity.
We need to build in a way that lets developers quickly build new features, primarily with components that require no customization. Simultaneously, we need to give developers and designers the space to iterate on new patterns, ideally within the constraints of the design system.
I love using Jason Lengstorf's term "progressive disclosure of complexity" as a model for how we can define interfaces that give us the best of both worlds.
The key, according to Jason, is offering multiple layers of abstraction to the system's consumers. They'll mostly operate at the highest layer of abstraction. But when they need something the system doesn't offer yet, they don't need to completely leave the system as a whole. They can just opt-in to a slightly lower layer of abstraction.
Primitives and Core Pattern
One way to design this type of system is a pattern that I'm calling the "Primitives and Core" pattern. I've stolen the name and some of the architectural ideas from a pattern Jack McCloy designed at Amplitude that he called "Bases and Variants." Adrianne Daley gets a big shoutout for suggesting the word "Core" instead of "Variants."
In this type of system, we can offer three layers of abstraction to developers.
Bottom layer (design tokens)
Design tokens comprise the bottom layer of the system. These is a purely data layer that provides all of the styling rules.
Middle layer (primitives)
Primitives define the middle layer. These are a set of components that are purely focused on functionality.
This layer could be adopted by any company, and it wouldn't make a difference to the brand. When building an internal design system in 2024, you probably wouldn't even want to build this layer from scratch -- you would use Shadcn, or Radix Primitives, or react-aria-components instead.
The primitives would accept a className
and would be possible to style in just about any way you could imagine.
Sample Interface
// all possible button props provided by React type ButtonPrimitiveProps = React.ComponentType<"button">; export const ButtonPrimitive = (props: ButtonPrimitiveProps) => { const { className, ...rest } = props; return <button className={className} {...rest} />; };
Top layer (core)
At the top layer, we provide the "core." These are a set of components that are built on top of the primitives and have a much narrower set of props.
We can consider offering an opportunity to do small, one-off overrides by offering a prop called UNSAFE_className.
This component would also include a variant
prop which would accept a string that defines which variant we're supporting.
Sample Interface
import { ButtonPrimitive, ButtonPrimitiveProps} from '../index.tsx'; type ButtonProps = Pick<ButtonPrimitiveProps, 'onClick'> & { UNSAFE_className: 'string'; variant: 'primary' | 'secondary' | 'tertiary'; } const getVariant = (variant: ButtonProps['variant']) => { return style[variant]; } export const Button = (props: ButtonProps) => { const buttonStyles = props.UNSAFE_className ? clsx(props.UNSAFE_className, getVariant(props.variant)) : getVariant(props.variant); return ( <ButtonPrimitive className={buttonStyles} > ); }
Why Follow This Pattern?
This type of system will allow nearly all developers to pull from core most of the time. But by exposing the primitives as well, developers will have the opportunity to opt-in to a lower layer of abstraction in order to achieve their goals.
We'll also be able to write scripts that track usages of primitive components so that we can collect new core of our components to pull back into our design system.
It also allows us to separate styling and behavioral concerns by keeping the primitives (pure functionality) apart from the core (styling applied via tokens).
What do you think? Reply to this email and let me know.