Back to Blog

Building a SwiftUI design system without overengineering it

A useful SwiftUI design system is not a giant abstraction layer. It is a small set of tokens, components, and rules that make product work faster without hiding the platform or freezing the app in theory.

9 min read

Most SwiftUI design systems go wrong in one of two directions.

Either the team has no system, so every screen invents spacing, colors, and button behavior again.

Or the team builds a capital-D Design System that acts like a second framework living awkwardly on top of SwiftUI.

The first turns product work into entropy.

The second turns normal UI work into archaeology.

The goal is simpler than both: make the common path consistent and cheap, without making uncommon work miserable.

That means a design system should help with repetition, not declare war on flexibility.

1. Start with decisions you actually repeat

A design system is not a Figma export plus a folder called DesignSystem.

It starts with the decisions your codebase keeps making over and over:

  • spacing scale
  • typography roles
  • color roles
  • corner radius and elevation rules
  • control sizing
  • common layouts for rows, cards, banners, empty states, and forms

If those decisions are not repeated often, they do not need a system yet.

This is where teams get carried away. They try to standardize every visual idea before the product has even stabilized. Now half the system is speculation.

A better rule is blunt:

  1. standardize what already repeats
  2. standardize what product explicitly wants consistent
  3. leave the rest alone until repetition shows up

You do not need twenty abstractions to avoid padding(.horizontal, 16) showing up in a few places.

2. Build tokens first, components second

If components come first, they usually smuggle styling decisions into one-off view code.

Then six months later you realize your “primary card” contains typography, spacing, shadow, and container rules nobody can reuse cleanly.

Tokens are the smaller contract. They define the basic language of the UI.

For example:

import SwiftUI

enum AppSpacing {
    static let xs: CGFloat = 4
    static let sm: CGFloat = 8
    static let md: CGFloat = 12
    static let lg: CGFloat = 16
    static let xl: CGFloat = 24
    static let xxl: CGFloat = 32
}

enum AppRadius {
    static let sm: CGFloat = 8
    static let md: CGFloat = 12
    static let lg: CGFloat = 16
}

enum AppColors {
    static let accent = Color.accentColor
    static let cardBackground = Color(uiColor: .secondarySystemBackground)
    static let borderSubtle = Color(uiColor: .separator)
    static let textPrimary = Color.primary
    static let textSecondary = Color.secondary
}

This is deliberately boring.

That is good.

Tokens should be boring because they exist to remove random choices, not to perform cleverness.

Once those are stable, components become easier to write and much easier to refactor.

3. Use semantic names, not paint-bucket names

blue500, gray200, and font14Medium look tidy until the product changes.

Now you either:

  • rename everything and cause churn
  • keep misleading names forever
  • add more aliases until the system feels like tax fraud

Semantic naming usually holds up better.

Prefer names like:

  • textPrimary
  • textSecondary
  • surfaceRaised
  • borderSubtle
  • contentPadding
  • screenGutter

Those names reflect usage, not implementation.

Implementation can change.

Usage usually changes more slowly.

The exception is when raw palette access is genuinely needed, for example data visualization or branded illustrations. Fine. Keep that separate from normal product UI.

4. Do not wrap native controls just because you can

This is one of the fastest ways to make a SwiftUI codebase annoying.

Teams create custom wrappers for everything:

  • AppText
  • AppButton
  • AppVStack
  • AppImage
  • AppSpacer, somehow

Now simple SwiftUI becomes a maze of proprietary aliases that hide standard behavior and make onboarding worse.

Most of the time, the better move is one of these:

  1. use native controls directly
  2. apply shared styling through ButtonStyle, ViewModifier, or environment values
  3. create a custom component only when it represents a real product pattern

For buttons, this is usually enough:

struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding(.vertical, AppSpacing.lg)
            .background(AppColors.accent)
            .clipShape(RoundedRectangle(cornerRadius: AppRadius.md, style: .continuous))
            .opacity(configuration.isPressed ? 0.92 : 1)
            .animation(.easeOut(duration: 0.15), value: configuration.isPressed)
    }
}

Then use it normally:

Button("Continue") {
    submit()
}
.buttonStyle(PrimaryButtonStyle())

That preserves SwiftUI’s model.

A giant AppButton(kind:size:icon:fullWidth:isLoading:isDestructive:...) usually does not.

5. Make the common components opinionated and small

A design system earns its keep when it removes repeated composition work.

Good candidates are patterns the product uses constantly:

  • section headers
  • settings rows
  • empty states
  • banners
  • cards
  • chips
  • loading placeholders

For example:

struct SettingsRow: View {
    let title: String
    let subtitle: String?
    let trailing: Trailing

    enum Trailing {
        case chevron
        case toggle(Bool)
        case text(String)
    }

    var body: some View {
        HStack(spacing: AppSpacing.md) {
            VStack(alignment: .leading, spacing: AppSpacing.xs) {
                Text(title)
                    .foregroundStyle(AppColors.textPrimary)

                if let subtitle {
                    Text(subtitle)
                        .font(.subheadline)
                        .foregroundStyle(AppColors.textSecondary)
                }
            }

            Spacer()

            switch trailing {
            case .chevron:
                Image(systemName: "chevron.right")
                    .foregroundStyle(AppColors.textSecondary)
            case .toggle(let isOn):
                Toggle("", isOn: .constant(isOn))
                    .labelsHidden()
                    .allowsHitTesting(false)
            case .text(let value):
                Text(value)
                    .foregroundStyle(AppColors.textSecondary)
            }
        }
        .padding(AppSpacing.lg)
        .background(AppColors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: AppRadius.md, style: .continuous))
    }
}

That is a useful abstraction because it matches a real repeated UI pattern.

It is not pretending to be the universal solution for all rows, all future product ideas, and possibly human sadness.

6. Separate brand, theme, and component behavior

These concerns get mixed together all the time.

They should not.

They change at different rates.

  • brand changes affect color, typography, maybe illustration style
  • theme changes affect light/dark, contrast, accessibility
  • component behavior affects layout, interaction, states

When those live in one blob, every change gets wider than it needs to be.

A practical split looks like this:

  1. tokens for spacing, radius, semantic colors, typography roles
  2. styles/modifiers for common visual treatment
  3. components for real product patterns
  4. feature views composing those pieces

That makes rebrands and visual refreshes less painful because the app is not welded to one giant bag of custom view wrappers.

7. Account for states early or your components will lie

A lot of components look clean only because they ignore real states:

  • loading
  • disabled
  • selected
  • error
  • multiline text
  • dynamic type
  • localization
  • long names from production data

Then the polished design system demo hits actual product screens and falls apart in under a week.

When defining a reusable component, ask:

  1. what are the expected states?
  2. what is fixed versus configurable?
  3. what content can grow unpredictably?
  4. what should happen in accessibility sizes?

For example, a button style without disabled treatment is incomplete.

A card component that assumes two lines of text forever is incomplete.

A banner that only looks correct in English is not a component. It is a screenshot with ambitions.

8. Use previews and snapshots to keep the system honest

Design-system code drifts when nobody sees state coverage together.

Previews are good for fast iteration.

Snapshots are good for protecting stable contracts.

For a reusable component, I want one place that shows the real states side by side:

#Preview {
    VStack(spacing: AppSpacing.lg) {
        SettingsRow(title: "Notifications", subtitle: "Mentions and replies", trailing: .chevron)
        SettingsRow(title: "Offline Mode", subtitle: nil, trailing: .toggle(true))
        SettingsRow(title: "Version", subtitle: nil, trailing: .text("2.3.1"))
    }
    .padding()
}

That catches regressions faster than discovering them through three unrelated feature screens a month later.

If the component is important enough to be shared, it is important enough to see in isolation.

9. Keep the API surface smaller than your ambition

Another classic mistake is turning every shared component into a mega-API.

You start with a card.

Then you add:

  • optional leading icon
  • optional trailing action
  • compact mode
  • prominent mode
  • selectable mode
  • destructive mode
  • loading mode
  • hero mode, for crimes

Now the component has eleven configuration branches, and nobody can predict how a new change will affect the other ten.

A cleaner pattern is:

  1. keep base components narrow
  2. compose variants from smaller pieces
  3. duplicate lightly when a pattern truly diverges

Mild duplication is cheaper than abstracting three different product ideas into one component that everybody resents.

This is the part teams resist because abstraction feels sophisticated.

But in UI systems, sophistication is often just delayed maintenance.

10. Let feature teams escape when needed

A design system that cannot be escaped will be escaped badly.

Sometimes a screen needs to break the pattern because:

  • the product is experimenting
  • marketing needs a one-off treatment
  • the interaction model is genuinely different
  • the shared component is not the right fit yet

That should be possible without a blood feud.

The system should make the common path easy and the uncommon path allowed.

If every deviation requires editing core shared code or adding one more generic parameter to a central component, the system is too rigid.

People will route around it.

Correctly.

11. A small folder structure is usually enough

You do not need a theology here.

Something like this is fine for many apps:

DesignSystem/
  Tokens/
    AppSpacing.swift
    AppColors.swift
    AppTypography.swift
    AppRadius.swift
  Styles/
    PrimaryButtonStyle.swift
    CardModifier.swift
  Components/
    SettingsRow.swift
    EmptyStateView.swift
    AppBanner.swift

That is enough structure to stay readable without turning the codebase into a museum of UI intentions.

If the design system gets big, split by domain carefully.

But do not start with six layers of architecture just because a conference talk made it sound noble.

12. The rule that keeps it sane

If I had to compress the whole thing into one rule, it would be this:

standardize decisions, not creativity.

The system should remove pointless variance:

  • random spacing
  • slightly different buttons
  • card styles that drift for no reason
  • inconsistent typography hierarchy

It should not make it harder to build a good screen.

That is the line.

Cross it, and the system starts serving itself instead of the product.

A practical baseline

If you are building or cleaning up a SwiftUI design system today, I would start here:

  1. define a spacing scale
  2. define semantic color roles
  3. define typography roles
  4. add one or two button styles
  5. extract the top five repeated product components
  6. review them against dynamic type and long-content states
  7. stop there until the next real pattern emerges

That last step matters.

A design system should grow from pressure in the product, not from somebody’s desire to build a tiny UI empire.

Keep it small. Keep it semantic. Keep it close to SwiftUI. And make sure it saves time for the people shipping screens instead of creating new rituals for them.

That is usually enough.