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.