SwiftUI Custom ViewModifiers: Reusable Patterns for Design Systems
Build custom SwiftUI ViewModifiers for a consistent design system. Patterns, best practices, and practical examples to style iOS views efficiently.

ViewModifiers form the foundation of a robust and maintainable SwiftUI design system. By encapsulating styles and behaviors into reusable components, they ensure visual consistency while drastically reducing code duplication. This modular approach transforms how modern iOS interfaces get built.
This article presents essential patterns for creating effective custom ViewModifiers, from basic structure to advanced compositions for a complete design system.
Anatomy of a ViewModifier
A ViewModifier is a protocol that defines a transformation applicable to any view. Unlike View extensions, ViewModifiers can maintain internal state and accept configurable parameters.
import SwiftUI
// Basic structure of a custom ViewModifier
struct CardModifier: ViewModifier {
// Configurable parameters
var cornerRadius: CGFloat = 12
var shadowRadius: CGFloat = 4
// The body method applies transformations
func body(content: Content) -> some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(cornerRadius)
.shadow(
color: .black.opacity(0.1),
radius: shadowRadius,
x: 0,
y: 2
)
}
}
// Extension for fluent syntax
extension View {
func cardStyle(
cornerRadius: CGFloat = 12,
shadowRadius: CGFloat = 4
) -> some View {
modifier(CardModifier(
cornerRadius: cornerRadius,
shadowRadius: shadowRadius
))
}
}The Content pattern represents the view on which the modifier applies. This abstraction allows the same modifier to work on any type of SwiftUI view.
Using the CardModifier in Practice
Once created, applying the modifier becomes intuitive and reads naturally in code:
struct ProductCard: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Product image
AsyncImage(url: product.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(height: 150)
.clipped()
// Product information
Text(product.name)
.font(.headline)
Text(product.price, format: .currency(code: "USD"))
.foregroundStyle(.secondary)
}
.cardStyle() // Applying the modifier
}
}
struct Product: Identifiable {
let id: UUID
let name: String
let price: Decimal
let imageURL: URL?
}Code clarity significantly improves maintainability. Changing the style of all cards in the application requires only a single change in the ViewModifier.
Conditional Modifiers with @ViewBuilder
ViewModifiers can include conditional logic to adapt their behavior based on context. The @ViewBuilder attribute enables building complex views with branching.
struct BadgeModifier: ViewModifier {
let count: Int
let color: Color
let showZero: Bool
init(count: Int, color: Color = .red, showZero: Bool = false) {
self.count = count
self.color = color
self.showZero = showZero
}
// @ViewBuilder enables conditions in the body
@ViewBuilder
func body(content: Content) -> some View {
if count > 0 || showZero {
content.overlay(alignment: .topTrailing) {
badgeView
}
} else {
// Returns the view without modification
content
}
}
// Badge view extracted for readability
private var badgeView: some View {
Text(count > 99 ? "99+" : "\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color)
.clipShape(Capsule())
.offset(x: 8, y: -8)
}
}
extension View {
func badge(
count: Int,
color: Color = .red,
showZero: Bool = false
) -> some View {
modifier(BadgeModifier(
count: count,
color: color,
showZero: showZero
))
}
}This pattern enables adding notification badges to any interface element in a declarative and configurable manner.
SwiftUI automatically optimizes @ViewBuilder branches. The non-displayed view doesn't render, preserving performance even with complex conditions.
Design System with Style Tokens
A mature design system relies on tokens: semantic constants that define the application's visual identity. ViewModifiers perfectly encapsulate these tokens.
import SwiftUI
// Spacing tokens
enum 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
}
// Radius tokens
enum Radius {
static let sm: CGFloat = 4
static let md: CGFloat = 8
static let lg: CGFloat = 16
static let full: CGFloat = 9999
}
// Typography tokens
enum Typography {
static let displayLarge = Font.system(size: 34, weight: .bold)
static let displayMedium = Font.system(size: 28, weight: .bold)
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let titleMedium = Font.system(size: 17, weight: .semibold)
static let bodyLarge = Font.system(size: 17, weight: .regular)
static let bodyMedium = Font.system(size: 15, weight: .regular)
static let caption = Font.system(size: 12, weight: .regular)
}
// Semantic color tokens
enum SemanticColor {
static let primary = Color("PrimaryColor")
static let secondary = Color("SecondaryColor")
static let success = Color.green
static let warning = Color.orange
static let error = Color.red
static let surface = Color(.systemBackground)
static let surfaceVariant = Color(.secondarySystemBackground)
}Token-Based ViewModifiers
These tokens integrate naturally into ViewModifiers that define the application's standard styles:
// Modifier for page titles
struct PageTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.displayMedium)
.foregroundStyle(Color.primary)
}
}
// Modifier for section titles
struct SectionTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.titleLarge)
.foregroundStyle(Color.primary)
}
}
// Modifier for secondary text
struct SecondaryTextModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.bodyMedium)
.foregroundStyle(.secondary)
}
}
// Fluent extensions for all text styles
extension View {
func pageTitle() -> some View {
modifier(PageTitleModifier())
}
func sectionTitle() -> some View {
modifier(SectionTitleModifier())
}
func secondaryText() -> some View {
modifier(SecondaryTextModifier())
}
}Usage becomes expressive and self-documenting, making code readable even for new team members.
Ready to ace your iOS interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Interactive Modifiers with State
ViewModifiers can manage their own state to create complex interactive behaviors. This pattern proves powerful for animations and user feedback.
struct PressableModifier: ViewModifier {
// Internal modifier state
@State private var isPressed = false
// Configuration
let scale: CGFloat
let opacity: CGFloat
let animation: Animation
init(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8,
animation: Animation = .easeInOut(duration: 0.1)
) {
self.scale = scale
self.opacity = opacity
self.animation = animation
}
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? scale : 1.0)
.opacity(isPressed ? opacity : 1.0)
.animation(animation, value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isPressed {
isPressed = true
}
}
.onEnded { _ in
isPressed = false
}
)
}
}
extension View {
func pressable(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8
) -> some View {
modifier(PressableModifier(scale: scale, opacity: opacity))
}
}Loading Animation with Shimmer Effect
Another example of internal state: the shimmer effect for loading placeholders:
struct ShimmerModifier: ViewModifier {
// Continuous animation
@State private var phase: CGFloat = 0
let duration: Double
let bounce: Bool
init(duration: Double = 1.5, bounce: Bool = false) {
self.duration = duration
self.bounce = bounce
}
func body(content: Content) -> some View {
content
.overlay(
shimmerOverlay
.mask(content)
)
.onAppear {
withAnimation(
.linear(duration: duration)
.repeatForever(autoreverses: bounce)
) {
phase = 1
}
}
}
private var shimmerOverlay: some View {
GeometryReader { geometry in
LinearGradient(
colors: [
.clear,
.white.opacity(0.5),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geometry.size.width * 2)
.offset(x: -geometry.size.width + (geometry.size.width * 2 * phase))
}
}
}
extension View {
func shimmer(duration: Double = 1.5) -> some View {
modifier(ShimmerModifier(duration: duration))
}
}
// Usage for loading skeletons
struct SkeletonCard: View {
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
RoundedRectangle(cornerRadius: Radius.md)
.fill(Color.gray.opacity(0.3))
.frame(height: 120)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(height: 20)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(width: 150, height: 16)
}
.shimmer()
}
}Composing ViewModifiers
The true power of ViewModifiers lies in their composition. Multiple modifiers can combine to create complex styles from simple building blocks.
// Base modifier for primary buttons
struct PrimaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(isEnabled ? SemanticColor.primary : Color.gray)
.cornerRadius(Radius.md)
}
}
// Modifier for secondary buttons (outline)
struct SecondaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(isEnabled ? SemanticColor.primary : .gray)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: Radius.md)
.stroke(
isEnabled ? SemanticColor.primary : Color.gray,
lineWidth: 2
)
)
}
}
// Extension combining style and interaction
extension View {
func primaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(PrimaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
func secondaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(SecondaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
}This composition enables creating variations without code duplication. Each modifier remains independently testable and modifiable.
The order of modifier application impacts the visual result. padding before background creates space between content and background, while the reverse adds padding around the background.
Adaptive Modifiers with Environment
ViewModifiers can read SwiftUI environment values to adapt to context: dark mode, accessible text size, orientation, and more.
struct AdaptiveCardModifier: ViewModifier {
// Reading environment values
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
func body(content: Content) -> some View {
content
.padding(adaptivePadding)
.background(backgroundColor)
.cornerRadius(Radius.lg)
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset
)
}
// Padding adapted to text size
private var adaptivePadding: CGFloat {
switch dynamicTypeSize {
case .xSmall, .small, .medium:
return Spacing.md
case .large, .xLarge:
return Spacing.lg
default:
return Spacing.xl
}
}
// Background color based on theme
private var backgroundColor: Color {
colorScheme == .dark
? Color(.secondarySystemBackground)
: Color(.systemBackground)
}
// More subtle shadow in dark mode
private var shadowColor: Color {
colorScheme == .dark
? .clear
: .black.opacity(0.1)
}
private var shadowRadius: CGFloat {
colorScheme == .dark ? 0 : 8
}
private var shadowOffset: CGFloat {
colorScheme == .dark ? 0 : 4
}
}
extension View {
func adaptiveCard() -> some View {
modifier(AdaptiveCardModifier())
}
}This modifier automatically adapts to system preferences without requiring additional code in the views that use it.
Organizing a Complete Design System
A structured design system groups ViewModifiers by functional category to facilitate discovery and maintenance:
// MARK: - Namespace for the Design System
enum DS {
// MARK: Text Styles
enum Text {
static func title(_ content: some View) -> some View {
content.modifier(PageTitleModifier())
}
static func section(_ content: some View) -> some View {
content.modifier(SectionTitleModifier())
}
static func body(_ content: some View) -> some View {
content.font(Typography.bodyLarge)
}
static func caption(_ content: some View) -> some View {
content.modifier(SecondaryTextModifier())
}
}
// MARK: Container Styles
enum Container {
static func card(_ content: some View) -> some View {
content.cardStyle()
}
static func adaptiveCard(_ content: some View) -> some View {
content.adaptiveCard()
}
}
// MARK: Button Styles
enum Button {
static func primary(_ content: some View, enabled: Bool = true) -> some View {
content.primaryButton(isEnabled: enabled)
}
static func secondary(_ content: some View, enabled: Bool = true) -> some View {
content.secondaryButton(isEnabled: enabled)
}
}
// MARK: Effects
enum Effect {
static func shimmer(_ content: some View) -> some View {
content.shimmer()
}
static func pressable(_ content: some View) -> some View {
content.pressable()
}
}
}Using the Design System
This organization produces expressive code that documents its intent:
struct ProfileScreen: View {
let user: User
@State private var isLoading = false
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
// Header with title style
Text("Profile")
.pageTitle()
// User card
UserCard(user: user)
.adaptiveCard()
// Settings section
VStack(alignment: .leading, spacing: Spacing.md) {
Text("Settings")
.sectionTitle()
SettingsRow(title: "Notifications")
SettingsRow(title: "Privacy")
SettingsRow(title: "Appearance")
}
.adaptiveCard()
// Logout button
Button("Log Out") {
// Action
}
.secondaryButton()
}
.padding(Spacing.md)
}
}
}
struct UserCard: View {
let user: User
var body: some View {
HStack(spacing: Spacing.md) {
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.shimmer()
}
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(user.name)
.font(Typography.titleMedium)
Text(user.email)
.secondaryText()
}
Spacer()
}
}
}Testing ViewModifiers
ViewModifiers can be tested independently to ensure correct behavior:
import XCTest
import SwiftUI
final class ViewModifierTests: XCTestCase {
func testCardModifierAppliesCorrectCornerRadius() {
// Arrange
let modifier = CardModifier(cornerRadius: 20, shadowRadius: 4)
// Assert
XCTAssertEqual(modifier.cornerRadius, 20)
}
func testBadgeModifierHidesWhenCountIsZero() {
// Badge should not display if count = 0 and showZero = false
let modifier = BadgeModifier(count: 0, showZero: false)
// Verify behavior via snapshot tests or UI tests
}
func testPressableModifierInitialState() {
// Initial state should be not pressed
// Testable via ViewInspector or UI tests
}
}The ViewInspector library allows inspecting SwiftUI view content in unit tests, facilitating ViewModifier validation.
Conclusion
ViewModifiers stand as the preferred tool for building a consistent and maintainable SwiftUI design system. Their ability to encapsulate styles, animations, and behaviors into reusable components transforms the architecture of modern iOS applications.
SwiftUI Design System Checklist
- ✅ Define design tokens (spacing, radius, typography, colors)
- ✅ Create atomic ViewModifiers for each style
- ✅ Use
@ViewBuilderfor conditional modifiers - ✅ Manage internal state with
@Statefor interactions - ✅ Compose modifiers to create complex styles
- ✅ Read environment for automatic adaptation
- ✅ Organize modifiers in a clear namespace
- ✅ Provide View extensions for fluent syntax
- ✅ Test modifiers independently
- ✅ Document usage with concrete examples
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

SwiftUI Performance: Optimizing LazyVStack and Complex Lists
Optimization techniques for LazyVStack and SwiftUI lists. Reduce memory consumption, improve scrolling performance, and avoid common pitfalls.

SwiftUI @Observable vs @State: When to Use What in 2026
Master the differences between @Observable and @State in SwiftUI to choose the right state management tool for your iOS applications.

SwiftUI: Building Modern Interfaces for iOS
Learn to create modern user interfaces with SwiftUI: declarative syntax, components, animations and best practices for iOS 18.