Creating a Custom ButtonStyle Systematically
Goal
By the end of this tutorial, you will build a reusable button style that supports size variants, pressed feedback, disabled behavior, and consistent usage across screens.
State it explicitly:
“Define one SwiftUI ButtonStyle contract and reuse it instead of ad hoc modifier chains.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+
The use case
Buttons are usually the first place where design inconsistency appears. One screen has 14pt radius, another has 12pt. One uses opacity on press, another uses scale. Disabled states differ, and call-to-action hierarchy drifts over time.
Teams often patch this with shared view modifiers, but those can still allow too many local overrides.
ButtonStyle is a better boundary because it centralizes interaction and visual state for buttons.
Step 1 - The simplest working version
Start with one primary style with clear pressed feedback.
import SwiftUI
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.accent)
)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.opacity(configuration.isPressed ? 0.9 : 1)
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
}
}
Use it:
Button("Continue") {
// action
}
.buttonStyle(PrimaryButtonStyle())
What this already does correctly: one style controls visual and interaction behavior across all usages.
Quick verification checkpoint: use this button in two screens and confirm pressed motion is identical.
Step 2 - Make it reusable
Now add size and tone variants to avoid style duplication.
enum AppButtonTone {
case primary
case secondary
}
enum AppButtonSize {
case compact
case regular
}
struct AppButtonStyle: ButtonStyle {
let tone: AppButtonTone
let size: AppButtonSize
func makeBody(configuration: Configuration) -> some View {
let metrics = metricsForSize(size)
return configuration.label
.font(metrics.font)
.foregroundStyle(foregroundForTone(tone))
.padding(.horizontal, metrics.horizontalPadding)
.padding(.vertical, metrics.verticalPadding)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: metrics.radius, style: .continuous)
.fill(backgroundForTone(tone, pressed: configuration.isPressed))
)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
}
private func metricsForSize(_ size: AppButtonSize) -> (font: Font, horizontalPadding: CGFloat, verticalPadding: CGFloat, radius: CGFloat) {
switch size {
case .compact:
return (.subheadline.weight(.semibold), 12, 8, 10)
case .regular:
return (.headline, 16, 12, 14)
}
}
private func foregroundForTone(_ tone: AppButtonTone) -> Color {
tone == .primary ? .white : .accentColor
}
private func backgroundForTone(_ tone: AppButtonTone, pressed: Bool) -> Color {
switch tone {
case .primary:
return pressed ? .accent.opacity(0.8) : .accent
case .secondary:
return pressed ? .accent.opacity(0.12) : .accent.opacity(0.08)
}
}
}
What changed: one style type now supports multiple design-system choices without branching at each call site.
Quick verification checkpoint: render primary+secondary and compact+regular variants side by side to validate token consistency.
Step 3 - SwiftUI-specific refinement
Add disabled-state handling and accessibility support through environment.
struct AppButtonStyle: ButtonStyle {
let tone: AppButtonTone
let size: AppButtonSize
@Environment(\.isEnabled) private var isEnabled
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
func makeBody(configuration: Configuration) -> some View {
let metrics = metricsForSize(size)
return configuration.label
.lineLimit(1)
.minimumScaleFactor(dynamicTypeSize.isAccessibilitySize ? 0.9 : 1)
.font(metrics.font)
.foregroundStyle(foregroundForTone(tone).opacity(isEnabled ? 1 : 0.6))
.padding(.horizontal, metrics.horizontalPadding)
.padding(.vertical, metrics.verticalPadding)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: metrics.radius, style: .continuous)
.fill(backgroundForTone(tone, pressed: configuration.isPressed).opacity(isEnabled ? 1 : 0.5))
)
.scaleEffect(configuration.isPressed && isEnabled ? 0.98 : 1)
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
.accessibilityAddTraits(.isButton)
}
// same helpers as previous step
}
Refinement points:
- style responds to
isEnabledautomatically - accessibility sizes keep labels readable
- interaction feedback is disabled when action is disabled
Quick verification checkpoint: add one enabled and one disabled button in preview and verify contrast and motion behavior differ correctly.
Result
You now have a button style system that is:
- consistent
- scalable
- easier to audit in design reviews
Common SwiftUI pitfalls
-
Mistake: Styling each button with local modifier chains
Why it happens: fast iteration per screen
How to fix it: move shared interaction and visuals intoButtonStyle -
Mistake: Ignoring disabled behavior in style logic
Why it happens: style built only around happy path
How to fix it: readisEnabledfrom environment and style accordingly -
Mistake: Too many style variants with no token system
Why it happens: uncontrolled one-off additions
How to fix it: define finite tone/size enums and map to design tokens
When NOT to use this
If one feature needs highly custom interaction that does not behave like the rest of your UI, a dedicated button view may be cleaner than forcing it into a shared style contract.
Takeaway
If you remember one thing: A good ButtonStyle centralizes interaction behavior, not just color and radius.
Next steps
If you want to connect component styling with architecture and reuse strategy, these are the strongest follow-ups: