Button Variant Style
A button style with filled, tinted, outlined and plain variants that follows the control size, the disabled state and the destructive role.

- Buttons
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- button · button style · filled · tinted · outlined · plain · destructive · control size · primary · secondary · botón · estilo de botón · relleno · tintado · contorno · destructivo · tamaño · primario · secundario
Description
Section titled “Description”A button style with four variants that reads its size, its enabled state and its role from the environment, like the system styles: .controlSize(.small) makes it small, .disabled(true) fades it, and a destructive role turns it red.
With Reduce Motion on, a press dims the button instead of shrinking it.
struct ButtonVariantStyleDemo: View { @State private var controlSize = ControlSize.regular @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize
private var rowLayout: AnyLayout { dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 12)) : AnyLayout(HStackLayout(spacing: 12)) }
var body: some View { ScrollView { VStack(spacing: 28) { Picker("Size", selection: $controlSize) { Text("Small").tag(ControlSize.small) Text("Medium").tag(ControlSize.regular) Text("Large").tag(ControlSize.large) } .pickerStyle(.segmented)
VStack(spacing: 16) { Button {} label: { Text("Continue").frame(maxWidth: .infinity) } .buttonStyle(.variant(.filled))
rowLayout { Button("Save", systemImage: "bookmark") {} .buttonStyle(.variant(.tinted)) Button("Share", systemImage: "square.and.arrow.up") {} .buttonStyle(.variant(.outlined)) }
Button("Not Now") {} .buttonStyle(.variant(.plain))
Divider()
rowLayout { Button("Delete", role: .destructive) {} .buttonStyle(.variant(.filled)) Button("Remove", role: .destructive) {} .buttonStyle(.variant(.tinted)) }
rowLayout { Button("Follow") {} .buttonStyle(.variant(.filled, color: .orange)) Button("Sold Out") {} .buttonStyle(.variant(.filled)) .disabled(true) } } .controlSize(controlSize) .padding(20) .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 24, style: .continuous)) } .padding(20) .animation(reduceMotion ? nil : .snappy, value: controlSize) } .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
variant | Variant | .filled |
color | Color | .accentColor |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A button style with four variants that reads its size, its enabled state and/// its role from the environment, like the system styles: `.controlSize(.small)`/// makes it small, `.disabled(true)` fades it, and a destructive role turns it red.////// With Reduce Motion on, a press dims the button instead of shrinking it.struct ButtonVariantStyle: ButtonStyle { enum Variant { case filled, tinted, outlined, plain }
var variant: Variant = .filled var color: Color = .accentColor
func makeBody(configuration: Configuration) -> some View { ButtonVariantStyleBody( configuration: configuration, variant: variant, color: configuration.role == .destructive ? .red : color ) }}
extension ButtonStyle where Self == ButtonVariantStyle { static func variant(_ variant: ButtonVariantStyle.Variant, color: Color = .accentColor) -> ButtonVariantStyle { ButtonVariantStyle(variant: variant, color: color) }}
private struct ButtonVariantStyleBody: View { let configuration: ButtonStyleConfiguration let variant: ButtonVariantStyle.Variant let color: Color
@Environment(\.controlSize) private var controlSize @Environment(\.isEnabled) private var isEnabled @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var metrics: (font: Font, horizontal: Double, vertical: Double, cornerRadius: Double) { switch controlSize { case .mini, .small: (.subheadline.weight(.semibold), 12, 7, 10) case .large, .extraLarge: (.title3.weight(.semibold), 24, 16, 16) default: (.body.weight(.semibold), 18, 12, 12) } }
/// The small size is shorter than 44 points, so only its touch area grows. private var touchSlop: Double { controlSize == .mini || controlSize == .small ? 6 : 0 }
private var foreground: AnyShapeStyle { variant == .filled ? AnyShapeStyle(ButtonVariantStyleContrastingForeground(background: color)) : AnyShapeStyle(color) }
var body: some View { let shape = RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) let isPressed = configuration.isPressed
configuration.label .font(metrics.font) .foregroundStyle(foreground) .padding(.horizontal, metrics.horizontal) .padding(.vertical, metrics.vertical) .background { switch variant { case .filled: shape.fill(color).brightness(isPressed ? -0.08 : 0) case .tinted: shape.fill(color.opacity(isPressed ? 0.25 : 0.15)) case .outlined, .plain: shape.fill(color.opacity(isPressed ? 0.12 : 0)) } } .overlay { if variant == .outlined { shape.strokeBorder(color, lineWidth: 1.5) } } .padding(.vertical, touchSlop) .contentShape(.rect) .padding(.vertical, -touchSlop) .scaleEffect(isPressed && !reduceMotion ? 0.96 : 1) .opacity(isEnabled ? (isPressed && reduceMotion ? 0.7 : 1) : 0.4) .animation(reduceMotion ? nil : .spring(duration: 0.25, bounce: 0.4), value: isPressed) }}
nonisolated struct ButtonVariantStyleContrastingForeground: ShapeStyle { let background: Color
func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white }}
// MARK: - Demo
struct ButtonVariantStyleDemo: View { @State private var controlSize = ControlSize.regular @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize
private var rowLayout: AnyLayout { dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 12)) : AnyLayout(HStackLayout(spacing: 12)) }
var body: some View { ScrollView { VStack(spacing: 28) { Picker("Size", selection: $controlSize) { Text("Small").tag(ControlSize.small) Text("Medium").tag(ControlSize.regular) Text("Large").tag(ControlSize.large) } .pickerStyle(.segmented)
VStack(spacing: 16) { Button {} label: { Text("Continue").frame(maxWidth: .infinity) } .buttonStyle(.variant(.filled))
rowLayout { Button("Save", systemImage: "bookmark") {} .buttonStyle(.variant(.tinted)) Button("Share", systemImage: "square.and.arrow.up") {} .buttonStyle(.variant(.outlined)) }
Button("Not Now") {} .buttonStyle(.variant(.plain))
Divider()
rowLayout { Button("Delete", role: .destructive) {} .buttonStyle(.variant(.filled)) Button("Remove", role: .destructive) {} .buttonStyle(.variant(.tinted)) }
rowLayout { Button("Follow") {} .buttonStyle(.variant(.filled, color: .orange)) Button("Sold Out") {} .buttonStyle(.variant(.filled)) .disabled(true) } } .controlSize(controlSize) .padding(20) .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 24, style: .continuous)) } .padding(20) .animation(reduceMotion ? nil : .snappy, value: controlSize) } .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) }}
#Preview { ButtonVariantStyleDemo()}