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

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 ViewModifier work 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: PrimitiveButtonStyle is the lower-level sibling of ButtonStyle. 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 applyIf can introduce structural identity problems. SwiftUI sees if/else branches 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)

ScenarioRecommendation
The same modifier chain appears in 3+ placesExtract to ViewModifier
You need press-state feedback on a buttonUse ButtonStyle
You want to rearrange Label’s icon/titleUse LabelStyle
The modifier must respond to environment valuesUse ViewModifier
You need to control when a button action firesUse PrimitiveButtonStyle
The styling is used in exactly one placeKeep it inline
Animate between two style statesParameterize the modifier

Summary

  • ViewModifier encapsulates reusable modifier chains into named, testable, parameterizable types.
  • Pair every ViewModifier with a View extension for an ergonomic call site.
  • ButtonStyle provides configuration.isPressed — use it instead of manual gesture recognizers for press feedback.
  • LabelStyle and ToggleStyle follow the same pattern and give you complete layout control over their respective components.
  • Avoid AnyView erasure in modifiers — use @ViewBuilder with 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.