Cross-Platform Design System: A 1.5-Year Journey
This article is a written version of the "Cross-Platform Design System: A 1.5-Year Journey" presentation from FEConf2023. The content is divided into two parts. Part 1 covers design systems and design tokens, and explores component composition as a way to address communication issues between designers and developers. Part 2 builds on Part 1, focusing on implementing components and designing APIs. All images in this article are from the presentation of the same name, and their sources are not individually cited. The presentation slides can be downloaded from the FEConf2023 website.
▶2024 FEConf Session Speaker Applications Open
▶2024 FEConf Lightning Talk Speaker Applications Open
"Cross-Platform Design System: A 1.5-Year Journey" presented at FEConf2023 by Hayoung, Frontend Engineer at Daangn
In this article, we'll explore how to apply the concepts we covered in "Cross-Platform Design System: A 1.5-Year Journey (Part 1)" to actual component implementation. The goals of this part are:
- Creating APIs that balance consistency and flexibility
- Designing packages with cross-platform compatibility in mind
- Understanding patterns for reflecting component specifications in code
Flexibility vs. Consistency
Let's revisit the APIs of Chakra and Spectrum. From a product language perspective, it's natural to want to provide a concise API like Spectrum's (shown on the right), prioritizing consistency. However, even if a consistency-focused approach covers 90% of use cases, users might still face development delays due to the remaining 10% of edge cases.
My approach to this problem is to separate packages into a core package that provides consistency and a composable package that offers flexibility.
In our actual implementation, we separate packages into three layers: Core, Composable, and Pre-Composed. We provide consistency by default using pre-composed components. We also offer styling props similar to those in Styled Components, but limit them to layout properties like margin. When more flexibility is needed, users can opt for the Composable package, which is designed to be just as easy to use as the pre-composed components.
Component Implementation
In the diagram above, the core logic of a component's functionality consists of state charts and DOM binding. For this article, we'll focus solely on DOM binding, leaving state charts for a future discussion.
Functionality: DOM Binding
Let's assume we need a component with the following requirements: it has 4 structural parts, maintains a 'checked' state, toggles when clicked, and prevents toggling when disabled.
- Structure - Root, Control, Input, Label
- State - isSelected: boolean
- Interaction - click
- Context - isDisabled: boolean
To implement DOM binding, let's first express the structure. We can declare the four structures as follows:
These props are ultimately spread into JSX to provide the desired functionality:
Applying state to the DOM involves determining element props based on the state received from the state chart. In this instance, we can bind isSelected to the checked prop of inputProps:
Interactions are implemented by event handlers that pass events to the state chart. In this case, when an onChange event occurs in inputProps, it sends a 'TOGGLE' event to the state chart. The state chart then determines whether the current state should change based on the 'TOGGLE' event and the isDisabled context, and passes the updated state back to the DOM binding logic.
Applying context is implemented similarly to applying state: by determining element props. The key difference is that it uses props injected from outside rather than the state provided by the state chart. In this case, we bind isDisabled from the injected ctx to the disabled prop of inputProps:
This logic, being free of React dependencies, is integrated with React via a thin wrapper:
We can declare interfaces for state and context separately, then extend them to include all callbacks for state changes. This allows us to declare all props required by the headless checkbox component:
The React wrapper can be implemented by simply receiving these props, passing them to the state chart, and returning the results to the DOM binding logic. In actual code, additional techniques like useSyncExternalStore or useEffect are needed for state synchronization with React, but they're omitted here for brevity:
Form
We've covered component implementation from a functional perspective. Now let's look at the presentational aspect (form). Since CSS and JavaScript are different languages, it's difficult to write wrappers analogous to hooks. We can't use CSS directly within JavaScript. Instead, we maintain consistency by generating both CSS and CSS-in-JS from a single schema.
Let's assume we have a component with these presentational requirements:
- Structure - Root, Control, Icon, Label
- Visual Options - size = large, medium
- State Options - selected
- Design Decisions - root height = 32px when large / 24px when medium / control background = primary when selected
First, let's define the structure. We start by defining the visual structure, similar to how we handled DOM binding for functionality:
We can define visual options using Variants expressions. In this case, we specify that the root's height is 32px when large and 24px when medium:
Next, we can define state options using data attributes as selectors. For example, we can implement it so that when the data-selected attribute exists, the control's background color changes to the primary color. However, HTML doesn't automatically add this data-selected attribute. Therefore, we also need to add related logic to the DOM binding:
The logic to pass the selected status as a data attribute to controlProps in the DOM binding is added as follows. This is necessary because state options were identified as a common concern between functionality and form:
Based on this schema, we can generate class names to be used as selectors, following rules based on structure and visual options:
We can also generate selectors for state options using CSS nesting syntax:
Finally, we generate CSS-in-JS code that corresponds to the generated CSS. The code below has two parts: the checkboxVariantProps interface representing visual options, and a function that converts these visual options into class names for each slot. For example, when size is passed as large, it returns class names like checkbox__root--size_large, as defined in the visual options example:
Ultimately, we complete the checkbox component interface by extending these functional and presentational interfaces and incorporating any necessary additional props:
We can call the functional hooks and the form's class name generation functions respectively, using the combined props. Looking at the JSX below the return statement, we can see that we spread the APIs obtained from the functional hooks and the class names obtained from the form logic into JSX, binding them to compose functionality and presentation. This code is very simple and repetitive, making it easy for users to reimplement using the Composable package:
This way, by separating pre-composed components from composable packages, we can provide APIs that offer both consistency and flexibility, achieving our original goal:
Since all React-dependent code is written as simple bindings, reusability across other frameworks has increased:
Furthermore, depending on how we define components, we can pre-calculate all possible rendering states of a component. This enables snapshot testing and QA automation:
At Daangn, we're also experimenting with automatically generating component specification documents for both Figma and the web using Figma Variables, and synchronizing them with the component code:
The component approach and implementation I've explained are all built on the shoulders of giants. I've been particularly influenced by Adobe Spectrum, Zag.js, and Class Variance Authority. I recommend that teams building design systems, or anyone wanting to understand them deeply, explore these libraries.
Conclusion
Let's summarize the topics we covered today:
- Setting design system goals
- Defining and utilizing design tokens
- Why atomic design can be confusing
- Breaking down component composition
- Solving communication issues between designers and developers
- Component implementation and API design
Based on this content, here are the key lessons I've learned. These are aspects I will definitely consider when creating a new design system:
- Clearly set design system goals and benchmarking targets
- Avoid premature abstraction when encoding design intent
- Separate component functionality/form to define minimum units
- Improve communication through state compression and eliminate state explosion
- Remove circular references in communication through separation of concerns
- Provide an environment where users can easily assemble components
Ultimately, achieving a balance between consistency and flexibility, based on the principles discussed, is the core lesson I've learned while building design systems. I hope this article empowers more developers to create design systems with confidence and enjoyment. Thank you.
Top comments (0)