# ButtonKit gives SwiftUI a button that can throw, await and show progress

> ButtonKit is a SwiftUI package that replaces the stock Button with an AsyncButton taking a throwing, asynchronous closure, and it adds failure animations, progress styles and state observation. It suits an app with network actions on its buttons, and it covers iOS 15 and up on Swift 6.

**Dean151/ButtonKit** — Asynchronous and Throwable button implementation for SwiftUI, with animations and progress tracking

- Repository: https://github.com/Dean151/ButtonKit
- Stars: 531 · Forks: 16
- Language: Swift
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/dean151-buttonkit

## The stock SwiftUI Button only accepts a closure that cannot fail

The problem is narrow and specific, and the package's own description of it is the clearest statement available. ButtonKit provides a new SwiftUI Button replacement to deal with throwable and asynchronous actions, and by default SwiftUI Button only accepts a closure. That is the whole gap. A closure that cannot throw means the caller has to swallow the error inside it, which in practice means a do-catch block that does nothing useful, or an optional error state that the view has to carry and render itself. With ButtonKit you get an `AsyncButton` view accepting a `() async throws -> Void` closure, so the error propagates out of the button the way it propagates out of any other Swift function. The package is MIT licensed, written in Swift, and the repository root shows a standard SwiftPM layout with `Sources/`, `Tests/`, a `Demo/` app project, a `Preview/` directory and a `skills/` folder.

## Adding the package is a SwiftPM dependency and one import

Installation goes through Swift Package Manager, and the manifest snippet in the documentation is the copy-paste version:

```swift
dependencies: [
    .package(url: "https://github.com/Dean151/ButtonKit.git", from: "0.7.0"),
],
targets: [
    .target(name: "MyTarget", dependencies: [
        .product(name: "ButtonKit", package: "ButtonKit"),
    ]),
]
```

Then the module is imported like any other:

```swift
import ButtonKit
```

The version requirement is Swift 6.0 with Xcode 16.0, and the platform floor is unusually wide: iOS 15 and above, iPadOS 15 and above, tvOS 15 and above, watchOS 8 and above, macOS 12 and above, and visionOS 1 and above. That last one matters if you are building for the headset, because a package that stops at iOS 15 would be a blocker there, and this one does not. The topics on the repository list the platform names alongside swiftui, swift-concurrency, swift6, activity-indicator, loading and progress, which tells you the package is aimed at exactly the problem of a button that has to show a spinner while it works.

## A thrown error shakes the button, and the style is replaceable

The failure behaviour is a design decision rather than an afterthought, and the default is a physical one. When the button closure throws, the button will shake by default, and the documentation shows two ways to express that, `.throwableButtonStyle(.shake)` and `.throwableButtonStyle(.symbolEffect(.wiggle))`. The second of those is interesting because it is a SwiftUI symbol effect rather than a bespoke animation, so the shake is built from the same primitive the system uses. You can turn the behaviour off by passing `.none` to `throwableButtonStyle`, and you can replace it entirely by implementing the `ThrowableButtonStyle` protocol, where you can implement `makeLabel`, `makeButton` or both to alter the button look and behaviour. The documented custom style shows the shape of that work, a struct with an `init` and a `makeLabel(configuration:)` that inspects `configuration.errorCount` and swaps the label for a Try again message when it is above zero, plus an extension that exposes it as a static property so it can be used as `.throwableButtonStyle(.tryAgain)`.

## Loading is tracked by suppressing duplicate tasks, not by disabling the button

The asynchronous behaviour has a specific and slightly counterintuitive default, and the documentation is explicit about it. When the process is in progress, another button press will not result in a new Task being issued, but the button is still enabled and hittable. So the protection against double submission lives in the task creation rather than in the disabled state, and the visual state is a separate concern. That separation is what the modifiers are for. The `disabledWhenLoading` modifier disables the button on loading, and `allowsHitTestingWhenLoading` disables hit testing when loading, with the documented call taking a boolean:

```swift
AsyncButton {
  ...
}
.allowsHitTestingWhenLoading(false)
```

While the progress is loading, the button will animate, defaulting by replacing the label of the button with a `ProgressView`. The built-in styles cover the alternatives, and the documentation lists them: `.overlay`, `.pulse`, `.leading`, `.trailing` and `.asyncButtonStyle(.symbolEffect(.bounce))`, with `.none` disabling the behaviour entirely.

## State observation is per button or across a whole group

The package lets you watch what a button is doing, and it does so in two granularities. For a single button, an `onStateChange` parameter gives you the state directly:

```swift
AsyncButton {
  ...
} onStateChange: { state in
  switch state {
  case let .started(task):
      // Task started
  case let .ended(completion):
      // Task ended, failed or was cancelled
  }
}
```

For several buttons at once, there is a group-level modifier. The documented pattern assigns each button an id and attaches `.onButtonStateChange` to the enclosing `Group`, and the event carries both the actual state of the button and the button identifier, so you can tell which one did what. If the id parameter is omitted, a UUID is generated for the button, which means the identifier is still available for logging and correlation even when you do not name the button yourself. There is a matching `.onButtonStateCancelled` modifier that hands you the cancelled button identifier, and a separate `.onButtonStateError` modifier for the error case, where the event carries the Swift `Error` and the button id.

## Styles are DynamicProperty, so they can read the environment

The customisation story is worth a close look, because it is where a package like this either stays a toy or becomes usable in a real app. Just like `ThrowableButtonStyle`, `AsyncButtonStyle` allows you to implement either `makeLabel`, `makeButton` or both to alter the button look and behaviour while loading is in progress. The important detail is that both style protocols conform to `DynamicProperty`, so your styles can use `@Environment`, `@State` or any other SwiftUI dynamic property directly. The documented example reads the accessibility reduce-motion setting:

```swift
public struct MyAsyncButtonStyle: AsyncButtonStyle {
    @Environment(\.accessibilityReduceMotion)
    private var reduceMotion

    public func makeButton(configuration: ButtonConfiguration) -> some View {
        // ...
    }
}
```

That is what makes the built-in animations defensible rather than merely decorative, because a custom style can respect the user's motion preference and the package does not get in the way of it. A team that has an in-house design system can implement the two protocols once and have every button in the app follow it.

## Triggering a button from elsewhere needs a namespace and an identifier

The last feature is the one that solves a real problem in SwiftUI, and it comes with its own setup cost. The scenario is a button that has to be triggered by a specific user action elsewhere in the interface, such as pressing the Send key on the virtual keyboard. The documentation is blunt about the consequence: to get the free animated progress and errors behaviour on your button, you cannot just start the action of the button yourself, the button has to start it. The mechanism is an identifier plus a namespace. You define a Hashable enum of your button identifiers, and the view holds a namespace:

```swift
enum LoginViewButton: Hashable {
  case login
}

struct ContentView: View {
    @Namespace private var triggerScope

    var body: some View {
        AsyncButton(id: LoginViewButton.login) {
            try await login()
        } label: {
            Text("Login")
        }
        .buttonTriggerNamespace(triggerScope)
    }
}
```

The `.buttonTriggerNamespace(triggerScope)` modifier attaches the scope to the button, and from any view the trigger becomes reachable through that scope. The identifier can be a String or any Hashable type, so an enum of cases is the natural choice, and it is the same id parameter used by the state observation modifiers.

## Where a button package is the wrong shape

Two limits are worth naming. The first is the platform floor. Swift 6.0 and Xcode 16.0, plus iOS 15, watchOS 8, macOS 12 and visionOS 1, means the package cannot be used in an app that still supports older systems, and a team that has not moved to Swift 6 concurrency cannot adopt it at all. That is a large part of the audience for a SwiftUI button. The second is that this solves one problem and leaves the rest of the loading state to you. The package owns the button, its progress animation and its error style, but the surrounding screen still has to decide what a failure means, whether to retry, and whether the action is idempotent. A button that will not fire a second task while one is in flight is a good default, but it is also a behaviour change from a stock button, and the documentation does not say what happens if the user wants to cancel, only that there is a state for cancellation and a modifier that reports it. The alternative to weigh against is writing the button view inside your own design system, where the loading and error behaviour is one of many components you already own and where the state machine for a network action is shared with the rest of the app. The difference in approach is between a drop-in replacement that other developers have already configured, and a house component where the button is a special case of the app's general loading model. For a Swift 6 codebase with a handful of network actions, the package saves a surprising amount of repetition. For a codebase with an established component library, it is one more dependency to reconcile.

## Conclusion

ButtonKit is a good fit for a SwiftUI app where buttons trigger network calls that can fail, because the `AsyncButton` closure is `() async throws -> Void` and the package handles the disabled state, the progress animation and the error feedback in one place instead of leaving three separate bugs to each screen. It is a poor fit for a project still on Swift 5, since the requirements are Swift 6.0 with Xcode 16.0, and a poor fit for a team that has already built its own button wrapper, because the style protocols are the part you would be replacing. Before adopting it, check the deployment targets of your app against the stated floor of iOS 15, watchOS 8, macOS 12 and visionOS 1, and read the error and cancellation handlers, because `.onButtonStateError` and `.onButtonStateCancelled` are where your error-reporting code will live. The last push to the repository was on 2026-08-05, with release 0.8.1 published on 2026-08-06.

## FAQ

### What problem does ButtonKit solve in SwiftUI?

It provides a SwiftUI Button replacement for throwable and asynchronous actions. By default SwiftUI Button only accepts a closure that cannot fail, while ButtonKit gives you an AsyncButton view accepting a () async throws -> Void closure.

### How do I add ButtonKit to a project?

Install it with Swift Package Manager by declaring the package dependency with the URL https://github.com/Dean151/ButtonKit.git from version 0.7.0, adding the ButtonKit product to your target, and importing the module with import ButtonKit. The requirements are Swift 6.0 with Xcode 16.0, and iOS 15, iPadOS 15, tvOS 15, watchOS 8, macOS 12 and visionOS 1 or above.

### What happens if I press a ButtonKit button twice while a task is running?

Another button press will not result in a new Task being issued, but the button is still enabled and hittable. You can disable it during loading with the disabledWhenLoading modifier, or turn off hit testing with allowsHitTestingWhenLoading(false).

### How do I handle errors and cancellation from a ButtonKit button?

Attach onButtonStateError to a group of buttons to receive an event carrying the Swift Error and the button identifier, and onButtonStateCancelled to receive the identifier of a cancelled button. A single button can also take an onStateChange parameter with started and ended cases, where the ended case covers a task that ended, failed or was cancelled.

## Sources

- [Dean151/ButtonKit on GitHub](https://github.com/Dean151/ButtonKit)
- [Issues](https://github.com/Dean151/ButtonKit/issues)
- [License: MIT](https://github.com/Dean151/ButtonKit/blob/main/LICENSE)
- [README](https://github.com/Dean151/ButtonKit/blob/main/README.md)
- [Releases](https://github.com/Dean151/ButtonKit/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dean151-buttonkit
