Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 091 / 100

Design systems in SwiftUI: a practical Swift & iOS guide

Consistent UI across the app.

Expona Admin8 min read

Design systems in SwiftUI: a practical Swift & iOS guide

Building a large iOS app means managing hundreds of UI components across multiple screens, and inconsistency creeps in faster than you'd expect. A proper design system in SwiftUI lets your team ship faster, maintain visual consistency, and scale without the chaos of color hex codes scattered across thirty files. This guide walks through the practical patterns we've used in production apps to create maintainable, reusable design systems.

Why design systems matter in SwiftUI

When we started our last e-commerce project, we had three different shades of blue in use within the first sprint. The checkout button looked slightly different from the filter button. Typography was all over the place. By week three, the design team was frustrated, the engineering team was defensive, and merges were a nightmare.

A design system solves this by centralizing decisions: one source of truth for colors, fonts, spacing, and component behavior. SwiftUI makes this straightforward because it's built around composition and view protocols. You're not fighting the framework; you're working with its grain.

Color and font tokens

Start with a simple DesignTokens structure or enum that holds all your colors and typography. This lives in a dedicated file and becomes the single reference point for the entire app.

swift
struct DesignTokens {
    struct Colors {
        static let primary = Color(red: 0.2, green: 0.4, blue: 0.8)
        static let secondary = Color(red: 0.95, green: 0.95, blue: 0.95)
        static let accent = Color(red: 1.0, green: 0.5, blue: 0.2)
        static let success = Color(red: 0.2, green: 0.7, blue: 0.4)
        static let error = Color(red: 0.9, green: 0.2, blue: 0.2)
        static let textPrimary = Color(red: 0.1, green: 0.1, blue: 0.1)
        static let textSecondary = Color(red: 0.5, green: 0.5, blue: 0.5)
        static let divider = Color(red: 0.9, green: 0.9, blue: 0.9)
    }
    
    struct Typography {
        static let headingLarge = Font.system(size: 28, weight: .bold)
        static let headingMedium = Font.system(size: 20, weight: .semibold)
        static let headingSmall = Font.system(size: 16, weight: .semibold)
        static let bodyLarge = Font.system(size: 16, weight: .regular)
        static let bodyRegular = Font.system(size: 14, weight: .regular)
        static let bodySmall = Font.system(size: 12, weight: .regular)
        static let caption = Font.system(size: 10, weight: .regular)
    }
    
    struct Spacing {
        static let xs: CGFloat = 4
        static let sm: CGFloat = 8
        static let md: CGFloat = 16
        static let lg: CGFloat = 24
        static let xl: CGFloat = 32
    }
}

In production, we often use asset catalogs for colors and custom font files, but this token-based approach works immediately and scales well. The key is consistency: every call to a primary button uses the same color, same font size, same corner radius.

Reusable button and card styles

Once you have tokens, build specific components that use them. A PrimaryButton encapsulates the design decision once, and the team uses it everywhere.

swift
struct PrimaryButton: View {
    let title: String
    let action: () -> Void
    var isLoading: Bool = false
    
    var body: some View {
        Button(action: action) {
            if isLoading {
                ProgressView()
                    .tint(.white)
            } else {
                Text(title)
                    .font(DesignTokens.Typography.bodyLarge)
                    .fontWeight(.semibold)
            }
        }
        .frame(maxWidth: .infinity)
        .frame(height: 48)
        .foregroundColor(.white)
        .background(DesignTokens.Colors.primary)
        .cornerRadius(8)
        .disabled(isLoading)
    }
}

struct SecondaryButton: View {
    let title: String
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            Text(title)
                .font(DesignTokens.Typography.bodyLarge)
                .fontWeight(.semibold)
        }
        .frame(maxWidth: .infinity)
        .frame(height: 48)
        .foregroundColor(DesignTokens.Colors.primary)
        .overlay(
            RoundedRectangle(cornerRadius: 8)
                .stroke(DesignTokens.Colors.primary, lineWidth: 1)
        )
    }
}

Now your screens use PrimaryButton(title: "Checkout", action: handleCheckout) instead of repeating padding, background colors, and corner radii. If the design team decides buttons should be slightly taller or have rounded corners instead of sharp ones, you change it once and it updates everywhere.

Build card styles the same way:

swift
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(DesignTokens.Spacing.md)
            .background(Color.white)
            .cornerRadius(12)
            .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2)
    }
}

extension View {
    func cardStyle() -> some View {
        self.modifier(CardStyle())
    }
}

A product card in your e-commerce app becomes clean and focused:

swift
struct ProductCard: View {
    let product: Product
    
    var body: some View {
        VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
            Image(product.imageName)
                .resizable()
                .scaledToFill()
                .frame(height: 180)
                .clipped()
            
            Text(product.name)
                .font(DesignTokens.Typography.headingSmall)
                .foregroundColor(DesignTokens.Colors.textPrimary)
            
            Text("$\(String(format: "%.2f", product.price))")
                .font(DesignTokens.Typography.bodyLarge)
                .fontWeight(.semibold)
                .foregroundColor(DesignTokens.Colors.primary)
        }
        .cardStyle()
    }
}

ButtonStyle and LabelStyle protocols

For even more power, use SwiftUI's ButtonStyle and LabelStyle protocols. This lets you define appearance rules once and apply them globally without wrapping every button in a component.

swift
struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(DesignTokens.Typography.bodyLarge)
            .fontWeight(.semibold)
            .frame(maxWidth: .infinity)
            .frame(height: 48)
            .foregroundColor(.white)
            .background(DesignTokens.Colors.primary)
            .cornerRadius(8)
            .opacity(configuration.isPressed ? 0.8 : 1)
    }
}

struct SecondaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(DesignTokens.Typography.bodyLarge)
            .fontWeight(.semibold)
            .frame(maxWidth: .infinity)
            .frame(height: 48)
            .foregroundColor(DesignTokens.Colors.primary)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(
                        DesignTokens.Colors.primary,
                        lineWidth: configuration.isPressed ? 2 : 1
                    )
            )
    }
}

Then apply them directly to any button:

swift
Button("Add to Cart") {
    viewModel.addToCart()
}
.buttonStyle(PrimaryButtonStyle())

Button("Cancel") {
    dismiss()
}
.buttonStyle(SecondaryButtonStyle())

This approach is cleaner for simple buttons scattered across screens. The trade-off: you lose the convenience of built-in state management like loading spinners or disabled states. Use component wrappers (PrimaryButton) for complex behavior, and button styles for straightforward UI application.

Preview every component

A design system only works if the team actually uses it. That means making it easy to discover and understand. SwiftUI previews are your documentation.

swift
#Preview {
    VStack(spacing: DesignTokens.Spacing.lg) {
        VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
            Text("Buttons")
                .font(DesignTokens.Typography.headingSmall)
            
            PrimaryButton(title: "Primary", action: {})
            SecondaryButton(title: "Secondary", action: {})
            PrimaryButton(title: "Loading", action: {}, isLoading: true)
        }
        
        VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
            Text("Cards")
                .font(DesignTokens.Typography.headingSmall)
            
            ProductCard(product: Product.mockData[0])
            ProductCard(product: Product.mockData[1])
        }
        
        VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) {
            Text("Colors")
                .font(DesignTokens.Typography.headingSmall)
            
            HStack(spacing: DesignTokens.Spacing.sm) {
                Circle()
                    .fill(DesignTokens.Colors.primary)
                    .frame(width: 40)
                
                Circle()
                    .fill(DesignTokens.Colors.success)
                    .frame(width: 40)
                
                Circle()
                    .fill(DesignTokens.Colors.error)
                    .frame(width: 40)
            }
        }
        
        Spacer()
    }
    .padding(DesignTokens.Spacing.md)
}

This component catalog preview lives in your design system file and makes it trivial for anyone to see all available styles, test them in light and dark mode, and check how they respond to different screen sizes. New team members can explore the preview and understand your design system without reading docs.

Common mistakes

Overcomplicating tokens. Start simple. You don't need opacity variants, themed colors, or dynamic sizing logic on day one. Add complexity when you actually need it. We've seen teams create nested enum structures that are impossible to navigate.

Building too many component variations. Resist the urge to create PrimaryButtonSmall, PrimaryButtonMedium, PrimaryButtonLarge. Use a single component with parameters instead: PrimaryButton(title: "Buy", size: .small). This keeps maintenance burden low.

Forgetting dark mode. Your design tokens should account for dark mode early. Use semantic color names like backgroundColor and foregroundColor, not literal names like whiteBackground and blackText. SwiftUI's environment handles the rest.

swift
struct DesignTokens {
    struct Colors {
        static let background = Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor.black : UIColor.white })
        static let surface = Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor.darkGray : UIColor.lightGray })
    }
}

Not using mock data in previews. Your design system components should be previewable without hitting an API or database. Create static var mockData properties on your models for this purpose.

Key takeaways

  • Centralize all design decisions—colors, fonts, spacing—in a single DesignTokens structure. This eliminates inconsistency and makes global changes effortless.
  • Build reusable components like PrimaryButton and ProductCard that wrap tokens. These become the building blocks your team reaches for instinctively.
  • Use ButtonStyle and LabelStyle protocols for lightweight, globally-applicable styling without component wrapper overhead.
  • Preview your entire design system in a single file. This serves as both documentation and a testing ground for new components.
  • Start minimal. A well-organized, simple design system beats an over-engineered one that nobody understands.
  • Account for dark mode and accessibility from the start. Use semantic color names and test your components across device sizes and appearance modes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles