magnuskahr

writing code

SwiftUI Design System Considerations: Semantic Colors

When designing the API for your SwiftUI design system, you have several approaches to choose from. Over the past few years, this has been a persistent source of frustration for me—as I've never been satisfied with the available solutions.

I believe a proper design system API should be:

  • Easy to read and understand
  • Easy to use correctly
  • Type-safe

In this post, I'll examine common solutions to handle semantic colors and explain why and how I think we can do better. I will introduce my proof-of-concept macro: Materials, you can skip the following section if you dont wanna read about the problem it solves.

An Overview of Common Solutions

Extensions on SwiftUI.Color

The quick-and-dirty solution is to extend the SwiftUI Color view with your custom colors:

extension Color {
	static let babyBlue = Color(...)
}

I dislike this approach for three key reasons:

  1. Lack of clear distinction: Using SwiftUI’s built-in colors alongside design system colors makes them visually indistinguishable at call sites, increasing the risk of accidental misuse. Even if you namespace your custom colors as .brandBabyBlue or Color.brand.babyBlue, this approach introduces additional cognitive load: developers must remember both the namespace and the exact identifier, which slows comprehension and adds friction when writing or reviewing code. As the number of colors grows, this also compounds maintenance complexity—manual namespacing lacks structure enforcement, making it easy for unused or misused variants to creep in.
  1. Unintended hierarchical variants: SwiftUI extends ShapeStyle (which Color implements) with hierarchical variants, allowing you to write Color.babyBlue.secondary. Since our design system doesn't define any secondary baby blue variant, this capability becomes a liability that can lead to shipped bugs. I've seen production apps where developers accidentally used .background.secondary instead of the intended .secondaryBackground, creating subtle visual inconsistencies that only surface in specific user scenarios.
  1. Static nature: These colors are static and cannot fully adapt to their context.

Custom ShapeStyle

Since colors can typically be expressed as ShapeStyles—thanks to modifiers like .foregroundStyle() and .backgroundStyle()—we could create custom ShapeStyles (available since iOS 17):

struct BabyBlueShapeStyle: ShapeStyle {
	func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
	...
	}
}
extension ShapeStyle where Self == BabyBlueShapeStyle {
	static var babyBlue: Self { ... }
} 

This approach gives us access to the current environment, eliminating the third problem. However, the first two issues persist.

Custom View Extensions + Enum

A more concise solution involves providing extensions that mimic SwiftUI modifiers but are scoped to an internal type:

enum BrandFont { ... }
extension View {
	func font(brand font: BrandFont) -> some View {
		....
	}
}

Usage would look like:

Text("Hello")
	.font(brand: .title)

This approach addresses all previous problems effectively—it's type-safe, clearly scoped, and context-aware. However, it requires creating custom modifiers for every styling property (foreground, background, font, spacing, etc.), which creates significant maintenance overhead. In a comprehensive design system, you would need to duplicate most of SwiftUI's styling API, leading to code duplication and the potential for inconsistencies between your custom modifiers and SwiftUI's evolving API.

The SwiftUI Way

SwiftUI provides a native solution to this challenge. Remember that ShapeStyle includes hierarchical extensions? For colors, these variants are simply opacity variations, but other types can be more specific.

Consider ForegroundStyle (or simply .foreground), which allows you to design views around its variants:

VStack {
	Text(title).foregroundStyle(.foreground)
	Text(subtitle).foregroundStyle(.foreground.secondary)
	Text(subtitle2).foregroundStyle(.foreground.tertiary)
}

You can then set the hierarchical foreground style for your entire app at the root level:

ContentView()
	.foregroundStyle(.red, .yellow, .green)

The hierarchical extensions on ShapeStyle also define .quaternary and .quinary variants, but there's no way to customize these since the foreground style modifier accepts a maximum of three styles. When using the fourth or fifth variants, you simply get the tertiary style.

Like ForegroundStyle, there's also BackgroundStyle, but we can only set the primary level.

While I appreciate this API concept, it's unfortunately quite limited–and unfortunately, we cannot create hierarchical variants of our own styles.

Having given this much thought and experimentation, I have a solution that suits me well:

A Macro Solution

What I really like about the SwiftUI structure is quick variant access: .foreground vs. .foreground.secondary. But arbitrary chaining (e.g., .foreground.secondary.secondary) compiles despite representing an invalid state and undermining compile-time safety, highlighting the need for a controlled API.

The API I developed is used like this:

tokens.material(.foreground.secondary)

The ergonomics of this approach, calling a function with a path to a material, ensures direct and safe interaction without exposing ShapeStyle.

The path mechanism resolves materials based on structured references, providing clear semantics and safety at compile time; notice that the path is called level in the code.

func material<R>(_ level: Level<R>) -> some ShapeStyle {
    materials[keyPath: level.keyPath]
}

The material(_:) method does return a ShapeStyle, but as we specify our material in it as a parameter, and not on it, we should not face a situation where we write: tokens.material(.foreground).secondary.

The method is just part of the story though, let’s take a look on what the tokens and materials variables refers to:

struct Tokens {
    struct Materials {
        struct Foreground {
            let primary: AnyShapeStyle
            let secondary: AnyShapeStyle
            let warning: AnyShapeStyle
        }
        struct Background {
            let primary: AnyShapeStyle
            let secondary: AnyShapeStyle
        }
        let foreground: Foreground
        let background: Background
    }
    
    private let materials: Materials
    
    func material<R>(_ level: Level<R>) -> some ShapeStyle {
        materials[keyPath: level.keyPath]
    }
}

This is a simple struct that defines available materials and their levels (foreground and background). (Note: the properties use AnyShapeStyle for flexibility; you could restrict to colors if desired.)

Now, the Level struct encodes valid material paths and enforces compile-time guarantees against invalid chaining.

struct Level<Root> {
    let keyPath: KeyPath<Tokens.Materials, AnyShapeStyle>
    
    static var foreground: Level<Tokens.Materials.Foreground> {
        Level<Tokens.Materials.Foreground>(\.foreground.primary)
    }
    
    static var background: Level<Tokens.Materials.Background> {
        Level<Tokens.Materials.Background>(\.background.primary)
    }
}

extension Level<Tokens.Materials.Foreground> {
    var secondary: Level<Void> {
        Level<Void>(\.foreground.secondary)
    }
}

extension Level<Tokens.Materials.Foreground> {
    var warning: Level<Void> {
        Level<Void>(\.foreground.warning)
    }
}

extension Level<Tokens.Materials.Background> {
    var secondary: Level<Void> {
        Level<Void>(\.background.secondary)
    }
}

It gives us the two static instances foreground and background that each refer to their primary counterpart, and extensions that allow only valid sub-levels, preventing invalid chaining (e.g., .foreground.secondary.secondary) by design.

This is a somewhat simplified version, but still—there’s a lot of boilerplate code to write. Boilerplate code can be easily tackled using macros, so that is what I have done.

Materials is (as of writing, June 2025) a proof-of-concept macro, and it is used as follows:

@Materials(
    materials: [
        Material(
            name: "foreground",
            levels: ["secondary", "warning"]
        ),
        Material(
            name: "background",
            levels: ["secondary"]
        )
    ]
)
struct Tokens {
    // List other tokens like spacing, fonts etc. manually
    let bodyFont: Font
}

Doing so will:

  1. Create the Materials struct and nested material-level structs, with automatic type-erasure of ShapeStyle.
  2. Generate an initializer for the applied struct covering all member variables.
  3. Add a private materials property and a material(_:) accessor for controlled lookup.
  4. Generate the Levels struct to encode valid paths and enforce compile-time safety.

So we can create our tokens:

let tokens = Tokens(
    bodyFont: Font.body,
    materials: Tokens.Materials(
        foreground: Tokens.Materials.Foreground(
            primary: .black,
            secondary: .gray,
            warning: .red
        ),
        background: Tokens.Materials.Background(
            primary: .white,
            secondary: .black
        )
    )
)

And use it in our views:

Circle()
    .fill(
        tokens.material(.foreground.secondary)
    )

Conclusion

The approach I've shown here has made working with semantic colors feel less like a workaround and more like a first-class part of SwiftUI. It avoids the pitfalls of the usual solutions, gives you structure without sacrificing flexibility, and scales well as your design system grows. More importantly, it lets you write expressive, safe code that doesn't trip up your future self or your teammates.

Macros aren't magic, but they are a pragmatic way to remove noise and make the intent behind your styling clear. If you've ever been frustrated by the lack of structure in color definitions, or the brittleness of ad hoc naming conventions, this pattern might be worth exploring. It won’t be for everyone—but it’s the first approach I’ve used that I actually enjoy.

If you try it, I’d love to hear what breaks.