Theming the Zalando Design System

mock webpage design showcasing the usage of design tokens

Why theming?

As a design system evolves alongside with the brand it represents, there are often multiple occasions when a need to introduce variations arises. On the business side of things there may be use cases for part of the customer journey to have a distinct look and feel, or there may be sub-brands being part of a larger platform. The previous article on this blog gives a wider overview of the Zalando Design System. This article will focus instead on the challenges encountered in the development of theming capabilities.

Introducing variations into the system, without compromising the baseline brand identity and the benefits of reusing existing client components, is one of the main reasons to explore the concept of theming.

In the absence of a proper theming architecture, early attempts and explorations of "theming" had lead to a number of hacky solutions that quickly become hard to maintain and pose risks to the overall system stability. In the past we encountered numerous challenges, including hidden CSS overrides, local conditional logic, debatable API additions, and duplicated implementations. A comprehensive theming solution quickly evolved from a "nice to have" into a clear "must have".

On a very high level, a theming architecture is just another instance of the generic problem of balancing flexibility and usability. A very strict and consistent design system makes development extremely fast, but as a company evolves and business requirements start to deviate from the initially identified rules we observe an increase in development and maintenance efforts. In order to keep the system healthy, it quickly becomes a requirement to handle the newly introduced flexibility as part of the design system itself.

Coming up with a theming concept tailored to the company's strategy and envisioning long-term goals beyond immediate business needs, is one of the most challenging steps in this process. Too much or too little flexibility can lead to a system that is hard to use, becomes increasingly difficult and costly to maintain, extends over time and impacts the performance and the maintenance costs of the systems involved.

To give an idea of how theming is currently used at Zalando, the Designer Home is a good example. You might notice the use of monochromatic texts, larger and uppercase headings, and the usage of rounded icon buttons. Those changes are all implemented via a theme and can be easily enabled or disabled on any given page.

Defining boundaries

Imagine a design system as a list of properties that define how UIs of a particular product should look and function. Now, consider theming as a mechanism to allow changing the values of a subset of those properties. Using this perspective there are two main areas of influence to shape a theming architecture: defining properties, and defining their allowed values.

For example, we could have a highly constrained theming concept, where different themes are allowed to choose a text colour to be either black or red, and buttons to be either rectangular or with rounded corners. In order to implement those theming specifications, we will need to have two properties in the system to represent the text colour and the border radius of buttons, as well as a defined set of possible values for both (e.g. "black/red", and "0px/32px").

In reality, things are never this simple though, and identifying a relatively stable set of properties and values requires both a comprehensive understanding of how the design system is currently used, as well as a fair amount of abstract thinking and product vision on how it may evolve in the future. The balance between static (or implicit and hardcoded) properties and dynamic ones, defines what a theme can or cannot do, and when there is a discrepancy between those capabilities and the product requirements, the expected advantages quickly dissipate and new iterations on the concept will be required.

An important aspect to be discussed is the scope and area of influence of those "themable" properties. While it may not be immediately obvious, there is a clear distinction between defining a UI component in isolation, as opposed to in a specific composition. Should a theme be able to change how a button looks inside a product card, but not anywhere else? These kinds of questions are inherently connected to the wider topic of ownership. Where can we draw the line between generic UI components and business specific compositions? What part of a visual change in the end user experience can be expressed as a global theme change and which one as a localised business logic?

It’s very easy to confuse the concept of "theming" as a capability of a component library, with "theming" as a feature of a design system. Component libraries do not encompass the entire design system, but are merely a tool that follows its specifications and "implements" it for a specific purpose, for example building web pages.

Many of the popular open source design systems are showcased, documented, and advertised via their implementation; usually one or more component libraries for different platforms. One famous exception is Material Design, which from the beginning only described the design system in the form of a series of specs and guidelines.

This confusion between design specification and implementation gets mirrored in the misunderstanding on what "theming" means for those two different concepts. Most open source component libraries allow some level of theming via a number of different technical approaches, usually using config files, shared contexts, and some form of shared variables (design tokens). On the other hand, what "theming" means on the design layer, is often overlooked.

Typically, a default theme that aligns with the brand's character and identity is commonly used. Theming is then offered as a way to adapt it to different organisations, companies, design systems. It’s very rare for theming capabilities to be showcased as a way to express variations of the same design system. A common exception, though, is the usage of colours. Material Design is again a good example here because it was intended to be used by many different products and apps not necessarily related, keeping the interactions and the tactile "material" metaphor consistent, while allowing to play with a very large colour palette in order to introduce a level of identity and ownership. Other libraries often showcase theming capabilities with custom colour palettes, or defining dark mode themes.

At Zalando, being one company with a well defined visual identity, introducing the concept of theming raised a lot of questions around the related governance rules and processes. How many themes may we need? How different can they look? Who can/should own and create them? How to ensure a baseline visual identity? Those and many other questions can be very hard to answer, and we will have to address them as we iterate through the initial use cases.

Semantic design tokens

One of the very first challenges in making a design system themable is the process of "tokenization". There are a number of repeated values scattered across design specifications and source code that need to be extracted into variables, known as design tokens, which can then be dynamically changed by themes. For example, the same shade of orange might be used as the background colour for a button, as well as the colour of the wishlist icon. A simple initial approach would be to create a variable called orange holding the exact hex colour value and then consume it in the two different components.

What will happen if a new theme now wants the button to be green? Surely, we cannot simply reassign our orange variable to a green value, that’s a recipe for disaster. This leads us to an important second step: identifying the semantic roles of different tokens and name them accordingly. Instead of orange we could call it accent, there would no longer be any confusion when its value is changed to green, or any other colour.

[color.background] accent.value = "orange" [theme.foo] color.background.accent.value = "green"

While this may sound simple on paper, the reality can be extremely complex. While trying to identify a reasonable set of semantic tokens out of our existing design system, we had to go through many design iterations, often leading to significant changes to the existing specifications. This process reflects our dedication to evolving a system that wasn't originally designed from the beginning with semantic tokens in mind. We faced several common challenges, including managing a large number of tokens, inconsistencies in their usage, and a lack of clarity regarding which values should change together or not.

Among all the sweat and tears, though, this has been a great opportunity to assess the quality of the design system itself. It has resulted in substantial simplification, removal of unnecessary subtle variations, as well as increasing the level of parity and consistency across libraries implementation for other platforms (Android and iOS).

Once we got a stable set of global tokens, the next challenge we faced was how to express variations that do not apply to everything, but only to specific components. For example we could have a padding.small token and use it across many components, but what happens if we want the button component to use padding.small in one theme and padding.large in another one? We cannot change the meaning of padding.small globally as it would have repercussions way beyond that specific button.

This led to what we call "component-level theming", that ultimately is nothing more than an additional level of indirection between a token name and its final value. We can create a token button.padding with a value of {padding.small}, where we refer to another token rather than a value. This way a theme gains the flexibility to change the padding value used in the button, as well as define which globally padding values are allowed.

Colour schemes

At Zalando, we encountered various situations where we need to alter the usage of colours based on what background is used in order to satisfy accessibility colour contrast requirements as well as visually pleasant colour combinations. Many banners on the website dynamically pick a background colour based on the content of an image.

Banner with background colour based on the image
content

To satisfy those needs, we introduced the concepts of colour schemes, namely a monochrome-dark colour scheme to be used on dark (but not black) backgrounds, and a monochrome-light for the opposite use case. Counting the "default" look and feel, it means that we need to support three different colour combinations.

This solution, for us, predates the concept of themes, and we used to override the values of palette colours directly, without semantic tokens in the picture yet. When shaping the new theming architecture we had to take colour schemes into account and make them first class citizens of themes.

What "monochrome-dark" looks like in a given theme can be different from another one. This means that each individual theme needs to support three different colour schemes. With those requirements in mind, the logic to determine the value of colour related design tokens becomes more complex, and requires knowledge of the current active theme as well as the current colour scheme.

A constant source of confusion has been the relationship between colour schemes and native dark mode that the user could potentially want to enable from the operating system settings. While we always had full dark mode support in mind when implementing colour schemes, and their current architecture can simplify the creation of a native dark mode for Zalando, it would not necessarily be as simple as enabling the "monochrome-dark" colour scheme on the entire page.

Additional considerations will have to be made in order to proceed towards native dark mode. For example there would be a need to express the default background colour through its own semantic token, additionally we would need to clarify the relationship with themes and colour schemes. Would "dark" be treated as a new colour scheme to be supported by each theme? Would "dark" and "monochrome-dark" be the same thing? Can a colour scheme change depending on native dark mode?

All those questions lead to complex conversations about how themes are used, their purpose, and the impact they have on the user experience. In order to answer all of them, we may have to gradually iterate on those concepts in order to find out what works and what doesn’t.

Style dictionary

The core of our theming infrastructure is our design tokens repository. We use Style Dictionary as a framework, and we define tokens in a single source of truth that can be consumed by libraries implemented for different platforms. Style dictionary allows to use a shared data format that can then be transformed to adapt to the needs of all the consuming component libraries. For example it takes care of converting and using the right units and colour formats for web, Android and IOS. Additionally it can generate platform specific artefacts that can be bundled, published, and consumed independently.

Style Dictionary is also easy to customise to our specific needs. Particularly with our own "transforms" and “formats”, we can handle custom requirements in a well-tested and reusable way. Some interesting examples are a transform to handle a boolean "display" token type and map it to CSS properties on web while keeping it as a boolean for app consumption; or another transform that allows to apply transparency to colours in a cross platform way.

Formats, on the other hand, can be used to customise the files generated for each platform. We can run a single build, generating different artefacts, and then have independent pipelines to publish them. This allows teams from web, Android, and IOS, to independently adapt the format of tokens to their platform, without affecting the other ones.

[color.text] primary.value = "black" primary-dark.value = "white" [spacing] s.value = "1rem" s-desktop.value = "2rem" [theme.foo] color.text.primary.value = "blue" spacing.s.value = "1.5rem"

The TOML format allows to express the nested structure of tokens in a human friendly way. Within the tokens folder, we have distinct files for different categories, like spacing, colours, typography, etc. Each one creates a namespace for the tokens defined inside them. Concatenating all the files inside the tokens folder we obtain a single dictionary object that represents the "base" theme. Colour schemes and responsive variants for each tokens, instead, are expressed using extra tokens with predefined suffixes (e.g. -dark, -tablet, etc.).

A theme is created with a file located in a separate folder, which defines a dictionary mirroring the structure of the base theme, but includes only tokens that are changed. The final theme dictionary is then computed by deep merging the base theme object with the theme one. This approach establishes a direct inheritance of each theme from the base theme, and is particularly convenient when it is expected for a base visual identity to be maintained across multiple themes.

CSS Variables (WEB)

The main output format consumed by the web component library, is a custom CSS file containing all the tokens encoded as CSS variables. The variables are then consumed by our CSS framework, which in turn exposes a library of classes for our React components. Ultimately, when working on a component and consuming some classes to set the primary text colour, there's no need for any knowledge about themes, colour schemes, or screen sizes; but we can assume the value will be changed automatically based on the defined overrides. This effectively decouples the implementation of components from the context in which they may be used by providing a stable and reliable interface to get dynamic values from a list of available semantic tokens.

For this behaviour to happen automatically, themes, colour schemes, and responsive variants for each token are implemented using classes to scope the set of required overrides.

:root { --spacing-s: 1rem; --color-text-primary: black; } @media (min-width: 64rem) { :root { --spacing-s: 2rem; } } .dark { --color-text-primary: white; } .theme-foo { --primary: blue; --spacing-s: 1.5rem; }

This way, setting a theme or colour scheme class on a container, ensures that all its children, will resolve the tokens with the correct value. Relying on classes we are less dependent on more complex JavaScript based tooling and we can use different ways to add or remove the required classes based on the use case.

Another advantage of using variable overrides is that we can express a whole theme solely by the difference from the base one, allowing for smaller CSS size overhead and, possibly, to load a separate small CSS file for the theme only when needed. On the other hand a drawback of this approach is that multiple themes nested inside each other on the same page would not be possible without duplicating all the existing tokens, otherwise we would get unpredictable combinations depending on what each theme overrides or not. Thus far, this hasn’t been a problem as we do not anticipate multiple themes appearing on the same page given our priority of maintaining visual coherency for our users.

Even without nested themes, the possibility of having nested colour schemes poses similar challenges, and we had to handle colours less efficiently by duplicating all colour tokens for every colour scheme, even if they were unchanged. Additionally, given that CSS selector with same specificity are applied based on their order of definition, the only way to guarantee for the class of the closest themed parent to win, would be to have additional selectors for every possible nesting combination.

While the recently introduced :is selectors help in keeping the code readable, there is still no way to support arbitrary nesting, requiring us to impose a hard limit. In the near future, once supported in all major browsers, the CSS @scope at-rule should help solve most of those issues, and enable more complex nested theming capabilities.

.a, .b .a, .c .a, .a .a, .a .a .a, .a .b .a, /* etc... */ { --primary: red; } /* can be simplified to */ .a, :is(.a, .b, .c) .a, :is(.a, .b, .c) :is(.a, .b, .c) .a { --primary: red; } /* in the future, once @scope is supported */ /* this also allows for arbitrary nesting levels */ @scope (.a) { & { --primary: red; } }

One interesting caveat of using a class scope to override the value of variables is related to how the value of CSS variables is resolved. The same algorithm used to determine the specificity of CSS selectors is also used to determine when the class (or at-rule) override is enabled for a variable value. This becomes a bit complicated when the value of a variable is a reference to another variable.

For example given this CSS and HTML:

:root { --primary: black; --color: var(--primary); } .blue { --primary: blue; } .box { width: 100px; height: 100px; background-color: var(--color); }

<div class="blue"> <div class="box" class="box" /> </div>

We do not get the intuitively expected behaviour, and the box appears to be black instead of blue. This happens because the --color variable resolution happens on the :root scope based on the last matching value of --primary (black), counterintuitively --color won’t be reevaluated when --primary changes, unless a higher specificity selector requires so.

To address this, we can introduce an additional scope class to increase the specificity of our boxes

<div class="scope blue"> <div class="box" class="box" /> </div>

:root { --primary: black; } :root, .scope { --color: var(--primary); } .blue { --primary: blue; } .box { width: 100px; height: 100px; background-color: var(--color); }

Now, the behaviour is in line with our expectations. As components are always children of a possibly themed container, we can add a class to their root container to enable this scoped resolution whenever we want a token to refer to another token rather than a static value. This is especially beneficial in scenarios involving component-level theming.

:root { --color-text-primary: black; } .dark { --color-text-primary: white; } :root, .scope { --button-color-text: var(--color-text-primary); }

iOS

Having the great power of code generation, it was tempting to convert design tokens placed in TOML files into the final source code that could be consumed directly in any iOS project. At first, we attempted to map TOML directly to Swift, but encountered certain challenges. Firstly, this approach would have allowed any engineer to extend the existing theme with new attributes. Additionally, we also had to figure out how to automate publishing of new versions of the library. Carthage, the dependency manager for iOS we use, assumes that the dependency is placed in a git repository and one should provide a url to download and build it. This means that all the generated files should be committed and pushed to the GitHub repository, and pushing the generated source code was considered a bad practice.

With this in mind, we quickly added some base Swift files that describe a structure of a Theme manually, and switched our scripts to generate JSON files, which, in their turn, are not that harmful when committed automatically, as they're just resources and don't potentially include any business logic inside. Having JSONs as a way to populate themes with actual values should also give us flexibility in case we'll be considering downloading themes from some kind of server API.

The system architecture of the iOS library for consuming design tokens is simple: there is a Theme structure, that defines all the agreed attributes, and there is an entity called ThemeManager, that loads the stored JSON files and populates itself with all the known variations of a Theme. Now any theme can be accessed from this ThemeManager just by its name.

Applying a theme is a recursive process: a theme applied on a higher level, let's say, a screen, will be automatically applied to all its subviews, then to subviews of these subviews and so on. It doesn't matter, if any view doesn't change it's appearance depending on a theme, this doesn't affect the theme propagating process, but for ones that support the theming capability inside, the result will be visible at once.

Supporting the theming capability in different ZDS components, we faced a problem. The appearance of a component is described by a Style object, which is just a static structure, encapsulating all the necessary attributes, such as a background colour, padding values, font size, etc. And every component has multiple presets for this Style structure. For example, a Flag component can be default, positive or sale, and every such preset stores its own values for the same attributes. Changing a theme would mean recreating the same Style structure with different values. At this moment it seemed that we should store default, positive and sale Flag values for every theme separately, and adding a new theme would mean that a new variant of the same presets should be added for every component. Not very scalable, isn't it?

So we introduced StyleTokens. For every component that supports theming it's just an enum which lets us know which preset should be applied disregarding the actual values that come from a theme. Based on this StyleToken value, the actual Style structure is generated every time the appearance of the component should change.

Now that meant that the final look of every themable component depends on 3 inputs:

  • style token
  • theme name
  • color scheme

And every time some of these three are changed — the theming engine creates a new instance of the Style object which is used to redraw the view. Now we can switch themes and add as many of them as we like without thinking that we would need to modify existing components every time it happens.

Android

Theme resources for (BaseTheme & Child-themes) are generated in an android consumable resource format (we have 2 formats):

  • XML resources for Android ViewSystem
  • JSON files for Compose

These resources are then packaged and published as a library in our internal maven repository, ready to be consumed by the Android component library as well as directly in the Zalando App codebase.

XML

This is the most used format as of now, given that most of our components are still built in XML, in this format theming is generated in the form of tokens/attributes that are then made into theme XML classes/objects ready to be consumed i.e BaseTheme, Designer, etc... And these themes can be easily applied using their ids, i.e R.style.BaseTheme.

JSON for Compose

Here, we generate theme tokens in the form of JSON files that are also packaged and shipped in the same library. These files are then parsed and theming data is extracted from them in the ZDS library. A theming architecture is then built on top of this data. This theming solution is also represented as simple semantic tokens that are ready to be consumed in all Composables (components written in compose).

ColorSchemes

We support 3 colour Schemes:

  • Default
  • Mono-Light
  • Mono-Dark

Each theme is generated with these three colour schemes supported, and it gets to decide the actual colours for each one. The client/user of a certain theme can choose when and where (on which part of their screen) to apply a certain colour schemes.

In XML, we offer two colour schemes templates that when applied to certain sections of the screen handle the colour swapping to the Monochrome (Light or Dark) variants for each colour, and they work on all themes.

<style name="MonoLightScheme"> <item name="ColorSchemeType">MONO_LIGHT</item> <item name="colorBackgroundSecondary"> ?colorBackgroundSecondaryMono </item>

In Compose, both Theme and ColorScheme are chosen at the root of the ZdsTheme selector, due to the simplicity of using theming in compose, a new ZdsTheme``Composable can be used at any part of the experience to choose and apply any combination of a ZDS-Theme and a colour scheme that fits the requirements of that section of the screen.

@Composable fun ZdsTheme( zdsThemeType: ZdsThemeType = ZdsThemeType.BaseTheme, zdsColorScheme: ZdsColorScheme = ZdsColorScheme.Default, content: @Composable () -> Unit, ) { ... }

Component-level Theming

An additional layer or set of tokens that are intended to alter the visuals of a specific component without affecting the rest of components i.e Flag component, can be modified without affecting the rest of the visual language/theme thus all other components are safe when, for example, the default flag changes colour from primary to secondary or something else.

Conclusion

Theming a design system is a way to introduce variations in a controlled manner. Depending on the business use case, careful consideration should be taken on how the theming architecture is designed. One of the most challenging parts is to identify the properties that can be altered by a theme, as well as the possible values they may have.

Governance becomes a key aspect when introducing theming to a design system. Like any other source of variations, themes should be managed and maintained in a way that ensures the baseline visual identity is preserved. This includes defining the number of themes, how different they can look, who can create them, and how to ensure that the visual identity is maintained.

By leveraging a single source of truth for design tokens, it becomes possible to share the specifications of each theme across different platforms. This allows for a predictable styling of all components, and decouples the implementation of components from the themed context in which they are used.

- 위키
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-09 03:04
浙ICP备14020137号-1 $방문자$