Custom View Modifiers and Button Styles in SwiftUI
Every design system lives or dies by consistency. When a new designer joins and wants all buttons to use the brand font,
you shouldn’t need to hunt through 40 call sites — you need a single source of truth. SwiftUI’s ViewModifier protocol
and the ButtonStyle family give you exactly that.
This guide covers ViewModifier, ButtonStyle, LabelStyle, and ToggleStyle — everything you need to build a
composable design system. We won’t cover ShapeStyle or custom drawing; those are covered in
Custom Shapes in SwiftUI.
Contents
- The Problem
- The
ViewModifierProtocol - Custom
ButtonStyle - Custom
LabelStyleandToggleStyle - Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Imagine a Pixar film catalog app. Every movie card needs the same visual treatment: padding, a system background, rounded corners, and a subtle shadow. Without extracted modifiers, the call sites look like this:
// FilmCatalogView.swift
struct FilmCatalogView: View {
let films: [PixarFilm]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))]) {
ForEach(films) { film in
FilmCard(film: film)
.font(.headline)
.foregroundStyle(.primary)
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.08), radius: 8, y: 4)
}
}
}
}
}
That six-modifier chain appears identically on FilmCard, DirectorCard, AwardBadge, and StudioHighlight. When
design decides the corner radius should be 16 instead of 12, you have four places to update — and you’ll miss one.
The deeper problem: the logic of “what makes something look like a card” is now owned by the call site, not by a dedicated type. There’s no way to ask “does this view have card styling?” and no way to reuse the card concept across targets or packages.
The ViewModifier Protocol
Apple Docs:
ViewModifier— SwiftUI
ViewModifier requires a single method:
body(content: Content) -> some View. The Content type is an opaque representation of the view being modified — you
compose onto it just like you would any other View.
Here’s the card styling extracted into a dedicated modifier:
struct PixarCardStyle: ViewModifier {
var isSelected: Bool = false
func body(content: Content) -> some View {
content
.padding()
.background(
isSelected
? Color.accentColor.opacity(0.15)
: Color(.systemBackground)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.08), radius: 8, y: 4)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : .clear,
lineWidth: 2
)
)
}
}
Attaching it directly via .modifier(PixarCardStyle()) works, but it’s noisy. The idiomatic approach is a View
extension that reads as natural English at the call site:
extension View {
func pixarCardStyle(isSelected: Bool = false) -> some View {
modifier(PixarCardStyle(isSelected: isSelected))
}
}
The call site now becomes:
FilmCard(film: film)
.pixarCardStyle(isSelected: selectedFilm == film)
One line. One source of truth. The corner radius, shadow, and selection state are all in PixarCardStyle.
Reading Environment Values Inside a Modifier
Modifiers can read from the SwiftUI environment, which makes them dramatically more flexible. A modifier that adapts its shadow to the current color scheme requires no changes at the call site:
struct PixarCardStyle: ViewModifier {
var isSelected: Bool = false
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
let shadowOpacity = colorScheme == .dark ? 0.3 : 0.08
return content
.padding()
.background(
isSelected
? Color.accentColor.opacity(0.15)
: Color(.systemBackground)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(shadowOpacity), radius: 8, y: 4)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : .clear,
lineWidth: 2
)
)
}
}
Note: Environment reads inside
ViewModifierwork correctly because SwiftUI inserts the modifier into the view tree. The modifier participates in environment propagation the same way any view does.
Custom ButtonStyle
Apple Docs:
ButtonStyle— SwiftUI
While ViewModifier works on any view, ButtonStyle
is purpose-built for buttons. It gives you access to configuration.isPressed — a Bool that reflects whether the user
is actively pressing the button — without any gesture recognizer boilerplate.
Here’s a complete design system for a Pixar film app with three button variants:
// MARK: - Primary Button
struct PixarPrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.accentColor)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(
.spring(response: 0.3, dampingFraction: 0.6),
value: configuration.isPressed
)
}
}
// MARK: - Secondary Button
struct PixarSecondaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(Color.accentColor)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().strokeBorder(Color.accentColor, lineWidth: 1.5))
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.animation(
.spring(response: 0.3, dampingFraction: 0.6),
value: configuration.isPressed
)
}
}
// MARK: - Destructive Button
struct PixarDestructiveButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(
configuration.isPressed ? Color.red.opacity(0.8) : Color.red
)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.animation(
.spring(response: 0.3, dampingFraction: 0.6),
value: configuration.isPressed
)
}
}
Extend ButtonStyle to make the API feel native:
extension ButtonStyle where Self == PixarPrimaryButtonStyle {
static var pixarPrimary: PixarPrimaryButtonStyle { .init() }
}
extension ButtonStyle where Self == PixarSecondaryButtonStyle {
static var pixarSecondary: PixarSecondaryButtonStyle { .init() }
}
extension ButtonStyle where Self == PixarDestructiveButtonStyle {
static var pixarDestructive: PixarDestructiveButtonStyle { .init() }
}
Now the call site is as clean as Apple’s own system styles:
VStack(spacing: 16) {
Button("Watch Toy Story") { }
.buttonStyle(.pixarPrimary)
Button("Add to Watchlist") { }
.buttonStyle(.pixarSecondary)
Button("Remove from Library") { }
.buttonStyle(.pixarDestructive)
}
Tip:
PrimitiveButtonStyleis the lower-level sibling ofButtonStyle. Use it when you need to control when the action fires (e.g., a long-press button) rather than just how it looks.
Custom LabelStyle and ToggleStyle
The same pattern extends to other SwiftUI style protocols.
LabelStyle
Apple Docs:
LabelStyle— SwiftUI
LabelStyle lets you rearrange the icon/title
composition of a Label. The Pixar film catalog might need a badge-style label where the icon sits inside a colored
circle:
struct PixarBadgeLabelStyle: LabelStyle {
var color: Color
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 8) {
configuration.icon
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(color)
.clipShape(Circle())
configuration.title
.font(.subheadline)
}
}
}
// Usage
Label("Toy Story", systemImage: "star.fill")
.labelStyle(PixarBadgeLabelStyle(color: .yellow))
ToggleStyle
Apple Docs:
ToggleStyle— SwiftUI
ToggleStyle gives you complete control over the
toggle’s appearance. Here’s a “lamp” toggle for a Monsters, Inc. scare floor dashboard — a completely custom on/off
indicator:
struct ScareLampToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
HStack {
configuration.label
Spacer()
Image(
systemName: configuration.isOn
? "lamp.ceiling.on"
: "lamp.ceiling"
)
.font(.title2)
.foregroundStyle(configuration.isOn ? .yellow : .secondary)
.contentTransition(.symbolEffect(.replace))
}
}
.buttonStyle(.plain)
}
}
// Usage
Toggle("Scare Floor Lights", isOn: $scareFloorActive)
.toggleStyle(ScareLampToggleStyle())
Advanced Usage
Animated Modifiers
Adding animation inside a ViewModifier means the animation is encapsulated with the style. Every consumer gets the
animation automatically:
struct PixarCardStyle: ViewModifier {
var isSelected: Bool = false
func body(content: Content) -> some View {
content
.padding()
.background(
isSelected
? Color.accentColor.opacity(0.15)
: Color(.systemBackground)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.08), radius: 8, y: 4)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : .clear,
lineWidth: 2
)
)
// animation lives here — every consumer gets it for free
.animation(
.spring(response: 0.35, dampingFraction: 0.7),
value: isSelected
)
}
}
Conditional Modifier
A common pattern is applying a modifier only when a condition is true. You can implement this as a View extension:
extension View {
@ViewBuilder
func applyIf<T: View>(
_ condition: Bool,
transform: (Self) -> T
) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
// Usage: only add the badge for award-winning films
FilmCard(film: film)
.applyIf(film.hasAcademyAward) { view in
view.overlay(alignment: .topTrailing) {
AwardBadge()
.padding(8)
}
}
Warning: Overusing
applyIfcan introduce structural identity problems. SwiftUI seesif/elsebranches as different view types, which means existing views are destroyed and recreated rather than updated. Prefer passing the condition as a parameter to your modifier when the view should persist across changes. See SwiftUI Performance: Identifying and Fixing Unnecessary View Redraws for a deep dive on structural identity.
Avoiding AnyView Erasure
A tempting shortcut when building conditional modifiers is to erase to AnyView:
// ❌ Avoid: AnyView disables SwiftUI's diffing and harms performance
func conditionalModifier(_ condition: Bool) -> AnyView {
if condition {
return AnyView(self.padding().background(Color.accentColor))
} else {
return AnyView(self)
}
}
AnyView wraps the view in an opaque box. SwiftUI can no longer see the view’s type identity, so it can’t perform
structural diffing — every update causes a full view replacement. Use @ViewBuilder with explicit if/else branches or
parameterized modifiers instead.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| The same modifier chain appears in 3+ places | Extract to ViewModifier |
| You need press-state feedback on a button | Use ButtonStyle |
You want to rearrange Label’s icon/title | Use LabelStyle |
| The modifier must respond to environment values | Use ViewModifier |
| You need to control when a button action fires | Use PrimitiveButtonStyle |
| The styling is used in exactly one place | Keep it inline |
| Animate between two style states | Parameterize the modifier |
Summary
ViewModifierencapsulates reusable modifier chains into named, testable, parameterizable types.- Pair every
ViewModifierwith aViewextension for an ergonomic call site. ButtonStyleprovidesconfiguration.isPressed— use it instead of manual gesture recognizers for press feedback.LabelStyleandToggleStylefollow the same pattern and give you complete layout control over their respective components.- Avoid
AnyViewerasure in modifiers — use@ViewBuilderwith explicit branches or parameterized types instead.
With a consistent modifier vocabulary in place, the next step is making your UI feel alive. Check out SwiftUI Animations: Implicit, Explicit, Matched Geometry, and Custom Transitions to learn how to add spring physics and hero transitions to the card system you just built.