Building a Design System Module

As your modular app grows, you'll notice something: every feature module ends up building the same UI primitives. Styled buttons, card layouts, loading spinners, error views. Each team copies them locally, they drift apart, and suddenly your app has three different button styles and two different ways to show a loading state.

A DesignSystem module prevents this. It sits in the Common layer alongside Env, Logger, and Toolkit, giving every feature module a shared vocabulary of UI components.

You can find the complete working code for this post on GitHub.

Where it fits

The DesignSystem module sits at the Common level. It has no feature dependencies:

Common (Env, Logger, Toolkit, DesignSystem) - no feature dependencies
    ↑
Services (API + Domain) - network and business logic
    ↑
Features (Landmarks, Reservations...) - SwiftUI views

Feature modules import DesignSystem directly. Domain and API modules don't need it since they don't have views.

Update the package manifest:

// In targets:
.target(name: "DesignSystem"),

// Update feature targets to include DesignSystem
.target(
    name: "LandmarksFeature",
    dependencies: [.landmarksDomain, .env, .toolkit, .designSystem]
),
.target(
    name: "ReservationsFeature",
    dependencies: [.reservationsDomain, .env, .toolkit, .designSystem]
),

And the dependency alias:

extension Target.Dependency {
    static let designSystem: Target.Dependency = "DesignSystem"
    // ... rest of dependencies
}

Themed buttons

Start with the component every app needs: a button style. Instead of each feature defining its own, create a shared set:

import SwiftUI

public struct PrimaryButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    public init() {}

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 14)
            .background(
                isEnabled
                    ? Color.accentColor
                    : Color.gray.opacity(0.5),
                in: RoundedRectangle(cornerRadius: 12)
            )
            .opacity(configuration.isPressed ? 0.8 : 1.0)
    }
}

public struct SecondaryButtonStyle: ButtonStyle {
    public init() {}

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.accentColor)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 14)
            .background(
                Color.accentColor.opacity(0.1),
                in: RoundedRectangle(cornerRadius: 12)
            )
            .opacity(configuration.isPressed ? 0.7 : 1.0)
    }
}

extension ButtonStyle where Self == PrimaryButtonStyle {
    public static var primary: PrimaryButtonStyle { PrimaryButtonStyle() }
}

extension ButtonStyle where Self == SecondaryButtonStyle {
    public static var secondary: SecondaryButtonStyle { SecondaryButtonStyle() }
}

Now any feature module uses .buttonStyle(.primary) and gets the same look. Change it once in DesignSystem, every feature updates.

Card views

Cards are another universal component. A CardView wrapper that handles the background, corner radius, and shadow:

public struct CardView<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        content
            .padding()
            .background(.background, in: RoundedRectangle(cornerRadius: 12))
            .shadow(color: .black.opacity(0.08), radius: 8, y: 4)
    }
}

Usage in a feature module:

import DesignSystem

struct LandmarkRow: View {
    let landmark: Landmark

    var body: some View {
        CardView {
            HStack {
                VStack(alignment: .leading) {
                    Text(landmark.name)
                        .font(.headline)
                    Text(landmark.park)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer()
            }
        }
    }
}

Loading and error states

Every feature needs to handle loading and error states. Instead of each one implementing its own spinner and retry button, centralize them:

public struct LoadingView: View {
    let message: String

    public init(_ message: String = "Loading...") {
        self.message = message
    }

    public var body: some View {
        ContentUnavailableView {
            ProgressView()
                .controlSize(.large)
        } description: {
            Text(message)
                .foregroundStyle(.secondary)
        }
    }
}

public struct ErrorView: View {
    let message: String
    let retry: (() -> Void)?

    public init(_ message: String, retry: (() -> Void)? = nil) {
        self.message = message
        self.retry = retry
    }

    public var body: some View {
        ContentUnavailableView {
            Image(systemName: "exclamationmark.triangle")
                .font(.largeTitle)
                .foregroundStyle(.secondary)
        } description: {
            Text(message)
                .foregroundStyle(.secondary)
        } actions: {
            if let retry {
                Button("Try Again", action: retry)
                    .buttonStyle(.primary)
                    .frame(width: 200)
            }
        }
    }
}

public struct EmptyStateView: View {
    let title: String
    let message: String
    let systemImage: String

    public init(
        title: String,
        message: String,
        systemImage: String = "tray"
    ) {
        self.title = title
        self.message = message
        self.systemImage = systemImage
    }

    public var body: some View {
        ContentUnavailableView {
            Label(title, systemImage: systemImage)
        } description: {
            Text(message)
        }
    }
}

Now a feature can handle all three states cleanly:

struct LandmarkListView: View {
    @Environment(\.landmarkService) private var service
    @State private var landmarks: [Landmark] = []
    @State private var isLoading = true
    @State private var error: Error?

    var body: some View {
        Group {
            if isLoading {
                LoadingView("Loading landmarks...")
            } else if let error {
                ErrorView(error.localizedDescription) {
                    Task { await load() }
                }
            } else if landmarks.isEmpty {
                EmptyStateView(
                    title: "No Landmarks",
                    message: "Check back later for new landmarks to explore.",
                    systemImage: "map"
                )
            } else {
                List(landmarks) { landmark in
                    LandmarkRow(landmark: landmark)
                }
            }
        }
        .task { await load() }
    }

    private func load() async {
        isLoading = true
        error = nil
        do {
            landmarks = try await service.fetchLandmarks()
        } catch {
            self.error = error
        }
        isLoading = false
    }
}

Every feature uses the same loading, error, and empty state views. The user sees a consistent experience. The developer writes less code.

Status badges

Small components like badges often get duplicated. Centralize them:

public struct StatusBadge: View {
    let text: String
    let color: Color

    public init(_ text: String, color: Color) {
        self.text = text
        self.color = color
    }

    public var body: some View {
        Text(text)
            .font(.caption)
            .fontWeight(.medium)
            .padding(.horizontal, 8)
            .padding(.vertical, 3)
            .background(color.opacity(0.15), in: Capsule())
            .foregroundStyle(color)
    }
}

Theme values

For colors and spacing that need to be consistent across the app, use environment values:

public struct AppTheme: Sendable {
    public let cornerRadius: CGFloat
    public let cardPadding: CGFloat
    public let sectionSpacing: CGFloat

    public init(
        cornerRadius: CGFloat = 12,
        cardPadding: CGFloat = 16,
        sectionSpacing: CGFloat = 24
    ) {
        self.cornerRadius = cornerRadius
        self.cardPadding = cardPadding
        self.sectionSpacing = sectionSpacing
    }

    public static let `default` = AppTheme()
}

extension EnvironmentValues {
    @Entry public var appTheme: AppTheme = .default
}

Features can read theme values from the environment:

struct MyView: View {
    @Environment(\.appTheme) private var theme

    var body: some View {
        content
            .padding(theme.cardPadding)
            .clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius))
    }
}

What to put in DesignSystem and what not to

Put in DesignSystem:

  • Button styles
  • Card and container views
  • Loading, error, and empty state views
  • Typography helpers (if you go beyond system styles)
  • Color constants and theme tokens
  • Badge and tag components
  • Common layout helpers

Don't put in DesignSystem:

  • Feature-specific views (those stay in their feature modules)
  • Business logic of any kind
  • Service types or network code
  • Anything that imports a Domain module

The rule is simple: if two or more features need it and it's purely visual, it goes in DesignSystem. If it's specific to one feature, it stays in that feature module.

Preview catalog

A nice bonus of having a DesignSystem module: you can create a preview catalog that shows every component:

#Preview("Buttons") {
    VStack(spacing: 16) {
        Button("Primary Action") {}
            .buttonStyle(.primary)

        Button("Secondary Action") {}
            .buttonStyle(.secondary)

        Button("Disabled") {}
            .buttonStyle(.primary)
            .disabled(true)
    }
    .padding()
}

#Preview("States") {
    VStack(spacing: 32) {
        LoadingView("Fetching data...")
        ErrorView("Something went wrong") {}
        EmptyStateView(
            title: "Nothing Here",
            message: "No items to display."
        )
    }
}

#Preview("Badges") {
    HStack(spacing: 8) {
        StatusBadge("Active", color: .green)
        StatusBadge("Pending", color: .orange)
        StatusBadge("Cancelled", color: .red)
    }
}

This gives designers and developers a living reference of every shared component. Open the DesignSystem target in Xcode, look at previews, and see everything the design system offers.

In the next post, we'll tackle the final architectural challenge: what happens when your single package gets too big. We'll split the app into multiple SPM packages that share a common foundation, giving each team ownership of their package while still sharing types and utilities.