Sitemap

Creating Reusable Components in SwiftUI: Building Your Design System

11 min readJan 25, 2026

--

Part 18 of 100 in the SwiftUI Zero to Expert Series

Press enter or click to view image in full size

I joined a project once where every screen had its own button implementation.

Primary buttons, secondary buttons, outline buttons — each written from scratch, each slightly different.

Some had 12 pixels of padding, others had 16.

One developer used .semibold, another used .bold.

The app looked like it was designed by committee because, in a way, it was.

That experience taught me the value of reusable components.

A well-designed component library saves time, ensures consistency, and makes your codebase maintainable.

More importantly, it lets designers and developers speak the same language.

When someone says “use the primary button,” everyone knows exactly what that means.

Let me show you how I build design systems in SwiftUI.

Not just the code, but the thinking behind it.

Component Design Principles

1. Single Responsibility

Each component should do one thing well.

I learned this the hard way.

import SwiftUI

// Good: Single purpose
struct PrimaryButton: View {
let title: String
let action: () -> Void

var body: some View {
Button(title, action: action)
.buttonStyle(PrimaryButtonStyle())
}
}

// Bad: Too many responsibilities
struct MultiPurposeButton: View {
let title: String
let icon: String?
let style: ButtonType
let size: ButtonSize
let isLoading: Bool
let badge: Int?
// ... this becomes a maintenance nightmare
}

I once built a “universal” button component with 15 parameters.

Every time someone needed a new feature, they’d add another parameter.

Eventually, the component was so complex that developers started building their own buttons again — exactly what I was trying to prevent.

Now I prefer composition.

A PrimaryButton, a SecondaryButton, a LoadingButton that wraps other buttons.

Each component is simple; you compose them for complexity.

2. Configurable but Not Complex

The sweet spot is having just enough configuration options.

Too few and the component isn’t useful; too many and it becomes overwhelming.

import SwiftUI

struct Badge: View {
let text: String
var color: Color = .blue
var size: BadgeSize = .medium

enum BadgeSize {
case small, medium, large

var font: Font {
switch self {
case .small: return .caption2
case .medium: return .caption
case .large: return .subheadline
}
}

var padding: EdgeInsets {
switch self {
case .small: return EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 6)
case .medium: return EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)
case .large: return EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
}
}
}

var body: some View {
Text(text)
.font(size.font)
.fontWeight(.medium)
.foregroundColor(.white)
.padding(size.padding)
.background(color)
.cornerRadius(size == .large ? 8 : 4)
}
}

Three parameters: text (required), color (optional with default), size (optional with default).

Most uses will be Badge(text: "NEW").

When you need customization, it’s there.

3. Preview All Variants

Previews aren’t just for development convenience — they’re documentation.

When another developer looks at your component, the preview shows them what’s possible.

#Preview("Badge Variants") {
VStack(spacing: 20) {
Badge(text: "NEW", color: .green, size: .small)
Badge(text: "SALE", color: .red, size: .medium)
Badge(text: "FEATURED", color: .purple, size: .large)
}
.padding()
}

I create previews for every meaningful combination.

It catches visual bugs early and serves as a living style guide.

Building Core Components

Typography System

Every design system starts with typography.

Inconsistent fonts make an app feel amateur.

import SwiftUI

struct AppTypography {
// Font sizes
static let largeTitle: Font = .system(size: 34, weight: .bold)
static let title1: Font = .system(size: 28, weight: .bold)
static let title2: Font = .system(size: 22, weight: .bold)
static let title3: Font = .system(size: 20, weight: .semibold)
static let headline: Font = .system(size: 17, weight: .semibold)
static let body: Font = .system(size: 17, weight: .regular)
static let callout: Font = .system(size: 16, weight: .regular)
static let subheadline: Font = .system(size: 15, weight: .regular)
static let footnote: Font = .system(size: 13, weight: .regular)
static let caption: Font = .system(size: 12, weight: .regular)
}

struct AppText: View {
let text: String
var style: TextStyle = .body
var color: Color = .primary

enum TextStyle {
case largeTitle, title1, title2, title3
case headline, body, callout, subheadline
case footnote, caption

var font: Font {
switch self {
case .largeTitle: return AppTypography.largeTitle
case .title1: return AppTypography.title1
case .title2: return AppTypography.title2
case .title3: return AppTypography.title3
case .headline: return AppTypography.headline
case .body: return AppTypography.body
case .callout: return AppTypography.callout
case .subheadline: return AppTypography.subheadline
case .footnote: return AppTypography.footnote
case .caption: return AppTypography.caption
}
}
}

var body: some View {
Text(text)
.font(style.font)
.foregroundColor(color)
}
}

These sizes match Apple’s Human Interface Guidelines.

I used to create custom sizes, but aligning with the system means better accessibility support and a more native feel.

Color System

Colors are surprisingly tricky.

You need semantic names, not descriptive ones.

import SwiftUI

struct AppColors {
// Primary palette
static let primary = Color("Primary")
static let secondary = Color("Secondary")
static let accent = Color("Accent")

// Semantic colors
static let success = Color.green
static let warning = Color.orange
static let error = Color.red
static let info = Color.blue

// Neutral colors
static let background = Color(.systemBackground)
static let secondaryBackground = Color(.secondarySystemBackground)
static let tertiaryBackground = Color(.tertiarySystemBackground)

// Text colors
static let textPrimary = Color(.label)
static let textSecondary = Color(.secondaryLabel)
static let textTertiary = Color(.tertiaryLabel)
}

extension Color {
static let app = AppColors.self
}

Using Color(.systemBackground) instead of Color.white means dark mode support for free.

Using Color("Primary") means the color is defined in Asset Catalog and can change per appearance or accessibility setting.

The extension static let app = AppColors.self gives you autocomplete: type Color.app. and see all your app colors.

Button Components

Buttons are the heart of any design system.

I’ve settled on a structure that handles most use cases.

import SwiftUI


struct AppButton: View {
let title: String
var icon: String? = nil
var style: ButtonVariant = .primary
var size: ButtonSize = .medium
var isLoading: Bool = false
var isDisabled: Bool = false
let action: () -> Void

enum ButtonVariant {
case primary, secondary, outline, ghost, destructive
}

enum ButtonSize {
case small, medium, large

var horizontalPadding: CGFloat {
switch self {
case .small: return 12
case .medium: return 16
case .large: return 24
}
}

var verticalPadding: CGFloat {
switch self {
case .small: return 8
case .medium: return 12
case .large: return 16
}
}

var font: Font {
switch self {
case .small: return .subheadline.weight(.semibold)
case .medium: return .body.weight(.semibold)
case .large: return .title3.weight(.semibold)
}
}
}

var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: foregroundColor))
.scaleEffect(0.8)
}

if let icon = icon, !isLoading {
Image(systemName: icon)
}

Text(title)
}
.font(size.font)
.foregroundColor(foregroundColor)
.padding(.horizontal, size.horizontalPadding)
.padding(.vertical, size.verticalPadding)
.frame(maxWidth: style == .ghost ? nil : .infinity)
.background(backgroundColor)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: style == .outline ? 2 : 0)
)
}
.disabled(isDisabled || isLoading)
.opacity(isDisabled ? 0.5 : 1)
}

private var backgroundColor: Color {
switch style {
case .primary: return .blue
case .secondary: return .gray.opacity(0.2)
case .outline, .ghost: return .clear
case .destructive: return .red
}
}

private var foregroundColor: Color {
switch style {
case .primary, .destructive: return .white
case .secondary: return .primary
case .outline: return .blue
case .ghost: return .blue
}
}

private var borderColor: Color {
style == .outline ? .blue : .clear
}
}

The loading state automatically replaces the icon with a spinner and disables interaction.

The destructive style is red for dangerous actions like delete.

Ghost buttons are for less prominent actions.

Input Components

Text fields with validation, icons, and error states appear everywhere.

Building them once saves hours.


import SwiftUI

struct AppTextField: View {
let placeholder: String
@Binding var text: String
var icon: String? = nil
var isSecure: Bool = false
var error: String? = nil
@FocusState private var isFocused: Bool

var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 12) {
if let icon = icon {
Image(systemName: icon)
.foregroundColor(error != nil ? .red : (isFocused ? .blue : .gray))
}

if isSecure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(
error != nil ? Color.red : (isFocused ? Color.blue : Color.clear),
lineWidth: 2
)
)
.focused($isFocused)

if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
}
}
}

The border changes color based on focus and error state.

The icon tint follows the same logic.

Error messages appear below the field automatically.

Card Component

Cards are containers.

I use generics to make them flexible.

import SwiftUI

struct AppCard<Content: View>: View {
var padding: CGFloat = 16
var cornerRadius: CGFloat = 12
var shadowRadius: CGFloat = 5
@ViewBuilder let content: () -> Content

var body: some View {
content()
.padding(padding)
.background(Color(.systemBackground))
.cornerRadius(cornerRadius)
.shadow(
color: Color.black.opacity(0.1),
radius: shadowRadius,
x: 0,
y: 2
)
}
}

// Variant with header
struct AppCardWithHeader<Header: View, Content: View>: View {
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content

var body: some View {
VStack(alignment: .leading, spacing: 0) {
header()
.padding()
.background(Color.blue.opacity(0.1))

Divider()

content()
.padding()
}
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}

Using @ViewBuilder means you can put anything inside.

The card doesn’t care what content it wraps.

Avatar Component

User avatars need placeholder handling, size variants, and optional online indicators.

import SwiftUI

struct AppAvatar: View {
let imageURL: URL?
var size: AvatarSize = .medium
var placeholder: String = "person.fill"
var showBadge: Bool = false
var badgeColor: Color = .green

enum AvatarSize: CGFloat {
case small = 32
case medium = 48
case large = 72
case extraLarge = 96
}

var body: some View {
ZStack(alignment: .bottomTrailing) {
if let url = imageURL {
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
placeholderView
}
} else {
placeholderView
}
}
.frame(width: size.rawValue, height: size.rawValue)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.white, lineWidth: 2)
)
.overlay(alignment: .bottomTrailing) {
if showBadge {
Circle()
.fill(badgeColor)
.frame(width: size.rawValue * 0.3, height: size.rawValue * 0.3)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 2)
)
.offset(x: 2, y: 2)
}
}
}

private var placeholderView: some View {
Image(systemName: placeholder)
.font(.system(size: size.rawValue * 0.4))
.foregroundColor(.gray)
.frame(width: size.rawValue, height: size.rawValue)
.background(Color.gray.opacity(0.2))
}
}

The badge scales with the avatar size.

The icon in the placeholder scales too.

Everything stays proportional.

List Row Component

List rows with icons, subtitles, and navigation chevrons are everywhere in iOS apps.

import SwiftUI

struct AppListRow<Leading: View, Trailing: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder var leading: () -> Leading
@ViewBuilder var trailing: () -> Trailing
var action: (() -> Void)? = nil

var body: some View {
Button {
action?()
} label: {
HStack(spacing: 12) {
leading()

VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body)
.foregroundColor(.primary)

if let subtitle = subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}

Spacer()

trailing()

if action != nil {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

extension AppListRow where Leading == EmptyView {
init(
title: String,
subtitle: String? = nil,
@ViewBuilder trailing: @escaping () -> Trailing,
action: (() -> Void)? = nil
) {
self.title = title
self.subtitle = subtitle
self.leading = { EmptyView() }
self.trailing = trailing
self.action = action
}
}

extension AppListRow where Trailing == EmptyView {
init(
title: String,
subtitle: String? = nil,
@ViewBuilder leading: @escaping () -> Leading,
action: (() -> Void)? = nil
) {
self.title = title
self.subtitle = subtitle
self.leading = leading
self.trailing = { EmptyView() }
self.action = action
}
}

The extensions let you omit leading or trailing views when you don’t need them.

The chevron only appears when there’s an action — indicating the row is tappable.

Empty State Component

Empty states are often overlooked, but they’re crucial for good UX.

import SwiftUI

struct EmptyStateView: View {
let icon: String
let title: String
var message: String? = nil
var actionTitle: String? = nil
var action: (() -> Void)? = nil

var body: some View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.gray)

VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.semibold)

if let message = message {
Text(message)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}

if let actionTitle = actionTitle, let action = action {
AppButton(title: actionTitle, action: action)
.frame(width: 200)
}
}
.padding(40)
}
}

An empty inbox, no search results, first-time user with no data — all these need friendly, helpful empty states.

The optional action button guides users toward filling that empty space.

Loading States

Loading states are part of the experience.

Skeleton views feel more responsive than spinners.

import SwiftUI

struct LoadingView: View {
var message: String? = nil

var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)

if let message = message {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}

struct SkeletonView: View {
@State private var isAnimating = false

var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [
Color.gray.opacity(0.3),
Color.gray.opacity(0.1),
Color.gray.opacity(0.3)
],
startPoint: .leading,
endPoint: .trailing
)
)
.mask(
Rectangle()
.offset(x: isAnimating ? 200 : -200)
)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
isAnimating = true
}
}
}
}

struct SkeletonCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SkeletonView()
.frame(height: 150)

SkeletonView()
.frame(height: 20)

SkeletonView()
.frame(width: 150, height: 16)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
}
}

The shimmer effect tells users something is loading without the harsh appearance of a blank screen.

It also gives users a preview of the layout they’ll see.

Putting It All Together

Here’s how these components work in practice:

import SwiftUI


struct ComponentShowcase: View {
@State private var email = ""
@State private var password = ""
@State private var isLoading = false

var body: some View {
ScrollView {
VStack(spacing: 24) {
// Typography
VStack(alignment: .leading, spacing: 8) {
AppText(text: "Typography", style: .title2)
AppText(text: "Body text example", style: .body)
AppText(text: "Caption text", style: .caption, color: .secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)

Divider()

// Buttons
VStack(spacing: 12) {
AppText(text: "Buttons", style: .title2)

AppButton(title: "Primary", style: .primary) { }
AppButton(title: "Secondary", style: .secondary) { }
AppButton(title: "Outline", icon: "plus", style: .outline) { }
AppButton(title: "Loading", isLoading: true) { }
}

Divider()

// Inputs
VStack(spacing: 12) {
AppText(text: "Inputs", style: .title2)

AppTextField(
placeholder: "Email",
text: $email,
icon: "envelope"
)

AppTextField(
placeholder: "Password",
text: $password,
icon: "lock",
isSecure: true
)
}

Divider()

// Cards
VStack(spacing: 12) {
AppText(text: "Cards", style: .title2)

AppCard {
VStack(alignment: .leading) {
Text("Card Title")
.font(.headline)
Text("Card content goes here")
.foregroundColor(.secondary)
}
}
}

Divider()

// Avatars
VStack(spacing: 12) {
AppText(text: "Avatars", style: .title2)

HStack(spacing: 16) {
AppAvatar(imageURL: nil, size: .small)
AppAvatar(imageURL: nil, size: .medium, showBadge: true)
AppAvatar(imageURL: nil, size: .large)
}
}

Divider()

// Empty State
EmptyStateView(
icon: "tray",
title: "No Items",
message: "Start by adding your first item",
actionTitle: "Add Item"
) { }
}
.padding()
}
}
}

What I Know Now That I Wish I’d Known Then

Start with constraints. Don’t offer every option upfront.

Add parameters when you need them. Components that start flexible often become unwieldy.

Naming matters. PrimaryButton is better than BlueButton. Names should describe purpose, not implementation. Colors change; purposes don't.

Documentation is the preview. Your preview code is documentation. Make it comprehensive. Future developers (including future you) will thank you.

Consistency beats perfection. A consistent 8-pixel spacing system is better than perfect but inconsistent spacing. Pick rules and follow them.

The Gotchas That Caught Me

Generic views need type annotations sometimes.

When using @ViewBuilder with multiple generic types, Swift sometimes struggles to infer types. Add explicit annotations when the compiler complains.

Default parameter values and generics don’t always mix well.

If you have a generic parameter with a default value, sometimes you need extension overloads instead.

Testing components in isolation.

Your component might work perfectly in isolation but break when composed with others.

Test in real contexts early.

Asset catalog colors need fallbacks. If you reference Color("Primary") and that color doesn't exist in your asset catalog, the app won't crash—but you'll get a default color. Always verify your asset catalog is complete.

Building reusable components is essential for scalable SwiftUI apps:

  • Typography system for consistent text
  • Color system for theming
  • Button variants for different contexts
  • Input components with validation
  • Card and list components for content
  • Loading and empty states for UX

The project where every button was different?

I spent a weekend building a component library.

The next feature took half the time because the building blocks were ready.

More importantly, the app finally looked intentional instead of accidental.

That’s the real value of a design system.

Not just consistency — intentionality.

In the next article, we’ll explore Advanced List Techniques for complex data displays.

Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀

--

--