Skip to content

Design Tokens

Mike Schreiber edited this page Jun 15, 2022 · 10 revisions

Overview

Tokens

Design tokens come in three tiers.

  • Global tokens are effectively friendly names for constants. These have no semantic meaning on their own.
    • Example: grey 64 = #A3A3A3, medium icon size = 24 pt
  • Alias tokens are imbued with semantic meaning. They should be derived from global tokens. Color-based alias tokens will have a number variants for different contexts (e.g. light mode, dark mode, high contrast, etc) and may vary from endpoint to endpoint.
    • Example: neutral background 2 = gray 98 @ light, gray 36 @ dark
  • Control tokens define how a given control draws itself using alias tokens. This is most likely to be defined in a platform-specific manner.
    • Example: button background = neutral background 2 Our Swift representation of tokens will reflect his hierarchy, with a unidirectional dependency hierarchy. Controls should only depend on Control tokens, without ever directly referring to an alias or global token.

Theme customization

Our clients will need to customize these components in two different levels.

The first and simplest level is brand coloring. These apps all use identically-themed controls, with app-appropriate brand coloring replacing our standard blue. This will be provided by a simple color customization API, as described below.

The second, more interesting (and more challenging) level, is a richer themeing support required by some partners. These apps will require near-total customization of components, from colors to fonts to corner radiuses.

To enable this, the aforementioned Control Token classes will be fully customizable, with every property being individually replaceable. See below for more details.

APIs

Global tokens

Let’s start with the basic definition of our global tokens. Below is a sample subset of various types of global tokens.

public final class GlobalTokens {

    // MARK: - BrandColors

    public enum BrandColorsTokens: CaseIterable {
        case primary
        case shade10
        case shade20
        case shade30
        case tint10
        case tint20
        case tint30
        case tint40
    }
    lazy public var brandColors: TokenSet<BrandColorsTokens, ColorSet> = .init { token in
        switch token {
        case .primary:
            return DynamicColor(light: ColorValue(0x0078D4), dark: ColorValue(0x0086F0))
        case .shade10:
            return DynamicColor(light: ColorValue(0x106EBE), dark: ColorValue(0x1890F1))
        case .shade20:
            return DynamicColor(light: ColorValue(0x005A9E), dark: ColorValue(0x3AA0F3))
        case .shade30:
            return DynamicColor(light: ColorValue(0x004578), dark: ColorValue(0x6CB8F6))
        case .tint10:
            return DynamicColor(light: ColorValue(0x2B88D8), dark: ColorValue(0x0074D3))
        case .tint20:
            return DynamicColor(light: ColorValue(0xC7E0F4), dark: ColorValue(0x004F90))
        case .tint30:
            return DynamicColor(light: ColorValue(0xDEECF9), dark: ColorValue(0x002848))
        case .tint40:
            return DynamicColor(light: ColorValue(0xEFF6FC), dark: ColorValue(0x001526))
        }
    }

    // MARK: - BorderRadius

    public enum BorderRadiusToken: CaseIterable {
        case none
        case small
        case medium
        case large
        case xLarge
        case circle
    }
    lazy public var borderRadius: TokenSet<BorderRadiusToken, CGFloat> = .init { token in
        switch token {
        case .none:
            return 0
        case .small:
            return 2
        case .medium:
            return 4
        case .large:
            return 8
        case .xLarge:
            return 12
        case .circle:
            return 9999
        }
    }

    // MARK: Initialization

    public init() {}
    static let shared = GlobalTokens()
}

These tokens can be read or modified by creating an instance of GlobalTokens and using publicly-accessible subscript notation for any given TokenSet:

    let globalTokens = GlobalTokens()
    globalTokens.brandColors[.primary] = ColorSet(light: 0xEFF6FC, dark: 0x001526)

Alias Tokens

Alias tokens are similar to global tokens, with one large change: they should reference global tokens whenever possible. Their goal is to provide semantic meaning for the raw values defined in the global system.

public final class AliasTokens {
    // MARK: ForegroundColors

    public enum ForegroundColorsTokens: CaseIterable {
        case neutral1
        case neutral2
        case neutral3
    }
    lazy var foregroundColors: TokenSet<ForegroundColorsTokens, ColorSet> = .init { [weak self] token in
        guard let strongSelf = self else { preconditionFailure() }
        switch token {
        case .neutral1:
            return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey14],
                                lightHighContrast: strongSelf.globalTokens.neutralColors[.black],
                                dark: strongSelf.globalTokens.neutralColors[.white],
                                darkHighContrast: strongSelf.globalTokens.neutralColors[.white])
        case .neutral2:
            return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey26],
                                lightHighContrast: strongSelf.globalTokens.neutralColors[.black],
                                dark: strongSelf.globalTokens.neutralColors[.grey84],
                                darkHighContrast: strongSelf.globalTokens.neutralColors[.white])
        case .neutral3:
            return DynamicColor (light: strongSelf.globalTokens.neutralColors[.grey38],
                                lightHighContrast: strongSelf.globalTokens.neutralColors[.grey14],
                                dark: strongSelf.globalTokens.neutralColors[.grey68],
                                darkHighContrast: strongSelf.globalTokens.neutralColors[.grey84])
        }
    }

    // MARK: BackgroundColors

    public enum BackgroundColorsTokens: CaseIterable {
        case neutral1
        case neutral2
        case neutral3
    }
    public lazy var backgroundColors: TokenSet<BackgroundColorsTokens, ColorSet> = .init { [weak self] token in
        guard let strongSelf = self else { preconditionFailure() }
        switch token {
        case .neutral1:
            return DynamicColor(light: strongSelf.globalTokens.neutralColors[.white],
                                dark: strongSelf.globalTokens.neutralColors[.black],
                                darkElevated: strongSelf.globalTokens.neutralColors[.grey4])
        case .neutral2:
            return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey98],
                                dark: strongSelf.globalTokens.neutralColors[.grey4],
                                darkElevated: strongSelf.globalTokens.neutralColors[.grey8])
        case .neutral3:
            return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey96],
                                dark: strongSelf.globalTokens.neutralColors[.grey8],
                                darkElevated: strongSelf.globalTokens.neutralColors[.grey12])
        }
    }

    // MARK: Initialization

    public init(globalTokens: GlobalTokens? = nil) {
        if let globalTokens = globalTokens {
            self.globalTokens = globalTokens
        }
    }

    lazy var globalTokens: GlobalTokens = FluentTheme.shared.globalTokens
}

Reading and writing values works much the same way for Alias tokens as for Globals.

Control tokens

Control token sets are simply the collection of tokens used by a single control. These should utilize Alias tokens whenever possible, though it may be necessary to reference a global token in cases where no such alias exists.

A default implementation of the component’s theme will exist alongside the component, providing clients the opportunity to subclass and provide custom values for these tokens. Custom Control tokens can be provided for any control either upon instantiation or after presentation via a public state property.

open class CardNudgeTokens: ControlTokens {
    open var accentColor: ColorSet {
        return globalTokens.brandColors[.shade20]
    }

    open var accentIconSize: Int {
        return globalTokens.iconSize[.xxSmall]
    }
}

Sub-Control tokens

Tokens for controls contained within another control should be defined in the hosting control’s tokens as optional, then passed down as a custom subclass of the contained control’s tokens to the contained control through override tokens.

open class ListTokens: ControlTokens {
    open var dividerColor: DynamicColor? { nil }
}
private class CustomDividerTokens: DividerTokens {
    var listTokens: ListTokens
    init (listTokens: ListTokens) {
        self.listTokens = listTokens
        super.init()
    }
    override var color: DynamicColor {
        listTokens.dividerColor ?? super.color
    }
}

FluentTheme

As an app, the first step towards providing a custom Fluent UI appearance for your app is to subclass the specific Control theme object(s) that you wish to override. Want to change button corner radii? Subclass ButtonTokens and provide an alternate value. Once your custom tokens are available, they can be directly set on a control instance via a public property.

However, there is another way to set properties “globally”: FluentTheme.

FluentTheme represent a way to group together a custom set of tokens—global, alias, and control—and apply them holistically to a specific view hierarchy, window, or even the entire app.

For example, let’s say an app wants to remove all corner radii from Fluent. They can do so by overriding all BorderRadius properties on GlobalTokens like so:

let globalTokens = GlobalTokens()
GlobalTokens.BorderRadiusToken.allCases.forEach { token in
    globalTokens.borderRadius[token] = 0
}

Next, this custom instance of GlobalTokens can be used to initialize a FluentTheme:

let fluentTheme = FluentTheme(globalTokens: globalTokens)

The FluentTheme instance can then be attached to a rendering context such as a SwiftUI View or a UIWindow (see below for more details).

From here, our controls will fetch the appropriate FluentTheme from their current context and use its tokens to render. If no relevant tokens can be found, then we will fall back to the FluentUI default values.

Note that all approaches described in the following sections are fully interoperable. In fact, if your app mixes SwiftUI and UIKit, you’ll almost certainly need to implement both solutions. Feel free to provide the same custom theme implementation in both cases to conserve memory.

SwiftUI

Our framework provides a simple ViewModifier called fluentTheme(_:) which takes a FluentTheme instance as its only argument. Simply add this view modifier to the highest point in your view hierarchy in which you’d like the custom theme to apply, and we’ll take care of the rest.

public var body: some View {
    VStack {
        // ...
        // some views that contain Fluent components
        // ...
    }
    .fluentTheme(myCustomFluentTheme)
}

UIKit

Custom themes can be set via a public extension on UIWindow. Simply set the fluentTheme property on the window you wish to host your components in, and once again we’ll take care of the rest.

See below for a rough idea of how it’s implemented internally:

protocol FluentThemeable {
    var fluentTheme: FluentTheme? { get set }
}
 
extension UIWindow: FluentThemeable {
    private struct Keys {
        static var fluentTheme: String = "fluentTheme_key"
    }

    /// The custom `FluentTheme` to apply to this window.
    public var fluentTheme: FluentTheme? {
        get {
            return objc_getAssociatedObject(self, &Keys.fluentTheme) as? FluentTheme
        }
        set {
            objc_setAssociatedObject(self, &Keys.fluentTheme, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            NotificationCenter.default.post(name: .didChangeTheme, object: nil)
        }
    }
}
Clone this wiki locally