SwiftUI Tutorial · iOS 16+ · iOS 26 Liquid Glass

SwiftUI Button: Styles, Roles & Custom ButtonStyle

Everything you need to build production buttons in SwiftUI — from the built-in .bordered styles to fully custom ButtonStyle types, async actions, and iOS 26 Liquid Glass.

Last updated: 2026-07-17 10 min read By Ahmed Gagan, iOS Engineer
Quick Answer

A SwiftUI Button pairs an action closure with a label view: Button("Save") { save() }. Apply visual treatment with .buttonStyle(.bordered), .borderedProminent, or .plain, and communicate intent with .buttonStyle roles like Button(role: .destructive). For fully custom looks, conform a type to the ButtonStyle protocol and read configuration.isPressed. On iOS 26, .buttonStyle(.glass) and the .glassEffect() modifier render Apple's Liquid Glass material.

Core initializer
Button(action:label:) or Button(_:action:)
Built-in styles
.automatic, .bordered, .borderedProminent, .plain, .borderless
iOS 26 addition
.buttonStyle(.glass) and .glassEffect() Liquid Glass
Swift Kit support
The Swift Kit ships a reusable PrimaryButton with tokenized colors that adapts to light/dark automatically.

The anatomy of a SwiftUI Button

Every Button is two things: an action that runs on tap, and a label that describes it. The most common form uses a string convenience initializer, but you can pass any view as the label — an Image, an HStack of icon plus text, or a fully composed layout. Because the label is just a view, buttons compose exactly like the rest of SwiftUI. Prefer the trailing-closure action form for readability, and keep the action short; heavy work belongs in a view model, not inline in the closure.

Three ways to make a button
// String label
Button("Save") { save() }

// Explicit action + label view
Button(action: save) {
    Label("Save", systemImage: "tray.and.arrow.down")
}

// Icon-only, with an accessibility label
Button {
    save()
} label: {
    Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share")

Built-in button styles

SwiftUI ships several styles you apply with .buttonStyle. Reach for .borderedProminent for the primary call to action on a screen, .bordered for secondary actions, and .plain when you want the label to render with no chrome at all. You can also set .controlSize and .tint to fine-tune sizing and color without writing a custom style. Applying a style to a container propagates it to every button inside, so you rarely repeat yourself.

  • .borderedProminent — filled, high-emphasis primary action
  • .bordered — tinted capsule for secondary actions
  • .plain — no background, useful inside lists and toolbars
  • .tint(.red) and .controlSize(.large) refine any style
Applying built-in styles
VStack(spacing: 12) {
    Button("Continue") { advance() }
        .buttonStyle(.borderedProminent)
        .controlSize(.large)

    Button("Maybe later") { dismiss() }
        .buttonStyle(.bordered)

    Button("Skip") { skip() }
        .buttonStyle(.plain)
}
.tint(.indigo)

Roles: destructive and cancel

A button role tells SwiftUI the semantic meaning of the action so it can style and place it correctly. Button(role: .destructive) renders red inside menus and confirmation dialogs, and .cancel gets special treatment in dialogs and alerts. Roles are not just cosmetic — VoiceOver announces them, and confirmationDialog uses them to order and emphasize choices. Use them instead of hardcoding a red tint on a delete button.

Destructive role in a confirmation dialog
struct DeleteRow: View {
    @State private var confirming = false

    var body: some View {
        Button("Delete Account", role: .destructive) {
            confirming = true
        }
        .confirmationDialog("Delete this account?",
                            isPresented: $confirming,
                            titleVisibility: .visible) {
            Button("Delete", role: .destructive) { deleteAccount() }
            Button("Cancel", role: .cancel) { }
        }
    }
}

Custom buttons with the ButtonStyle protocol

When the built-in styles are not enough, conform a struct to ButtonStyle. Its makeBody(configuration:) receives a configuration whose label is your button's content and whose isPressed flag drives press feedback. This is the correct place to add scale-on-press animation, gradients, or shadows — it keeps interaction state out of your view and makes the style reusable across the whole app. Avoid re-implementing tap handling with gestures; ButtonStyle already gives you accessibility and hit-testing for free.

A reusable pressable style
struct CapsuleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .padding(.vertical, 12)
            .padding(.horizontal, 24)
            .background(Color.accentColor, in: Capsule())
            .foregroundStyle(.white)
            .scaleEffect(configuration.isPressed ? 0.96 : 1)
            .opacity(configuration.isPressed ? 0.85 : 1)
            .animation(.easeOut(duration: 0.15), value: configuration.isPressed)
    }
}

// Usage
Button("Get Started") { start() }
    .buttonStyle(CapsuleButtonStyle())

Async actions, disabled state, and iOS 26 Liquid Glass

A button action is synchronous, but you often want to await work. Wrap the call in a Task and drive a loading flag so you can disable the button and show a ProgressView while it runs. Use .disabled(_:) to gate the button on validation, and it will dim and stop responding automatically. On iOS 26, Apple introduced Liquid Glass — apply .buttonStyle(.glass) for a system glass button, or wrap custom content in .glassEffect() to float it above scrolling content. Gate glass code behind an availability check so iOS 16–25 users fall back gracefully.

Async button with loading state and glass
struct SubmitButton: View {
    @State private var isLoading = false
    let isValid: Bool

    var body: some View {
        Button {
            Task {
                isLoading = true
                defer { isLoading = false }
                try? await submit()
            }
        } label: {
            if isLoading { ProgressView() } else { Text("Submit") }
        }
        .disabled(!isValid || isLoading)
        .modifier(GlassIfAvailable())
    }
}

struct GlassIfAvailable: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 26, *) {
            content.buttonStyle(.glass)
        } else {
            content.buttonStyle(.borderedProminent)
        }
    }
}

Stop rebuilding the same button

The Swift Kit ships a tokenized PrimaryButton and pressable ButtonStyle wired into a light/dark design system, so your CTAs look consistent from day one.

Get The Swift Kit — $99

How to build a custom primary button in SwiftUI

Create a reusable, on-brand primary button you can drop anywhere in your app.

  1. 1

    Define the ButtonStyle

    Create a struct conforming to ButtonStyle and implement makeBody(configuration:).

    struct PrimaryButtonStyle: ButtonStyle { /* ... */ }
  2. 2

    Read isPressed

    Use configuration.isPressed to add a scale or opacity change for tactile feedback.

    .scaleEffect(configuration.isPressed ? 0.97 : 1)
  3. 3

    Apply and reuse

    Attach it with .buttonStyle(PrimaryButtonStyle()) on any Button in the app.

    Button("Save") { save() }.buttonStyle(PrimaryButtonStyle())

Frequently Asked Questions

How do I make a filled primary button in SwiftUI?
Apply .buttonStyle(.borderedProminent), which gives a filled, high-emphasis look. Combine it with .controlSize(.large) and .tint(_:) to control size and color. For a fully custom fill, write a ButtonStyle and set a background in makeBody.
How do I add an async action to a SwiftUI Button?
Button actions are synchronous, so wrap async work in a Task inside the action closure: Button { Task { await load() } }. Drive a @State loading flag to disable the button and show a ProgressView while the task runs.
How do I make a destructive delete button in SwiftUI?
Use Button("Delete", role: .destructive) { }. The role renders the label red in menus and confirmation dialogs and is announced by VoiceOver, which is preferable to manually tinting the button red.
How do I detect the pressed state of a SwiftUI Button?
Conform a type to ButtonStyle and read configuration.isPressed inside makeBody(configuration:). It is true while the finger is down, letting you animate scale, opacity, or color for press feedback.
How do I make an iOS 26 Liquid Glass button in SwiftUI?
On iOS 26 apply .buttonStyle(.glass) for a system Liquid Glass button, or wrap custom content with .glassEffect(). Guard the call with if #available(iOS 26, *) and fall back to .borderedProminent on earlier versions.

Keep exploring

Ship faster with a real design system

The Swift Kit is a $99 one-time SwiftUI boilerplate with buttons, paywalls, auth, and AI already wired up — lifetime updates and a 14-day refund.

Get The Swift Kit — $99

One-time purchase · Lifetime updates · 14-day refund