Apps can provide physical feedback through device vibration when users interact with them. For instance, when toggling a switch, confirming an action, or using controls such as pickers and sliders, apps can become more alive and engaging by providing various levels of vibration. That effect, known as haptic feedback, makes interactions feel more natural and responsive, improving the overall user experience.
At some point in the past I wrote a post about how to enable haptic feedback in SwiftUI based projects, but using UIKit APIs only. This time, we’re revisiting the same topic, aiming to meet and explore SwiftUI’s available APIs.
Haptic feedback in SwiftUI is enabled with the sensoryFeedback modifier, which is available as of iOS 17, macOS 14, and related platform releases. This modifier accepts a few arguments that define the feedback’s behavior. One thing that’s important to keep in mind is that haptic feedback must be used thoughtfully and only where it makes sense to exist. Enabling it all over the place in an app just because it’s available is against the best user experience and Apple’s guidelines.
SwiftUI provides common feedback styles based on their semantic meaning as SensoryFeedback values, including selection, success, error, warning, and more. We can also customize feedback with arbitrary weight and intensity values.
Having gone through this small introduction, let’s jump straight into some actual examples.
The selection feedback
Suppose that we have a button that allows users to toggle a favorite state. That’s a good opportunity to also provide an additional haptic feedback with the sensoryFeedback modifier:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var isFavorite = false var body: some View { Button(isFavorite ? "Favorite" : "Add to Favorites", systemImage: isFavorite ? "heart.fill" : "heart", action: { isFavorite.toggle() }) .sensoryFeedback(.selection, trigger: isFavorite) } } |
See that sensoryFeedback accepts two arguments, the selection which is a SensoryFeedback value, and the flag that triggers the feedback. A couple of things to note here:
selectionis one of the many predefined values with semantic meaning, and results in a light vibration that’s suitable to cases like this example.- The trigger does not have to be a Bool value only. Instead, it can be of any type, as long as it conforms to Equatable.
But most importantly, it’s essential to make clear the following:
It’s not the button press that enables the haptic feedback, but the change of the trigger value. If that value does not change, haptic feedback won’t play.
To see the code above working, run the app on a real device, not in the Simulator. But keep in mind that not all SensoryFeedback values work on all devices, nor they cause the same effect. The documentation behind each value shows the target devices.
Given that it’s not the control, but the trigger value that enables the haptic feedback, that means that we can apply sensoryFeedback to any control that updates such a value. For example, we can use it in a picker that shows a list of priorities, causing a feedback whenever the selected priority is changed:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct ContentView: View { enum Priority: String, CaseIterable { case low, normal, high } @State private var priority: Priority = .normal var body: some View { Picker("Priority", selection: $priority) { ForEach(Priority.allCases, id: \.self) { priority in Text(priority.rawValue.capitalized).tag(priority) } } .sensoryFeedback(.selection, trigger: priority) } } |
The success and error feedback values
Different sensory feedback values exist for cases where we need to indicate success, error, or warning. The device vibration is not the same as the one caused by the selection feedback we met before, but stronger, fitting better to such occasions.
As I mentioned in the previous part, it’s the trigger value that makes the device play a haptic feedback. But in addition to the sensoryFeedback modifier with two parameters we already met, there’s an initializer with a third parameter; a closure where we get the old and new value of the trigger, just like it happens in the onChange modifier.
That new sensoryFeedback initializer we use in the following example, where we examine the new value of the count property. When its value reaches a predefined threshold we set as condition, then the success haptic feedback is triggered:
|
1 2 3 4 5 6 7 8 9 10 |
@State private var count = 0 Button("Count: \(count)“) { count += 1 } .sensoryFeedback(.success, trigger: count) { oldValue, newValue in newValue > 5 } |
In particular, when newValue exceeds 5, then the success feedback makes the device vibrate.
Sometimes we may need to provide different feedback values as the result of the same action. To manage that, there’s another sensoryFeedback initializer that accepts only the trigger value and the closure as arguments. The difference here is that we have to return the desired SensoryFeedback value in the closure’s body. And moreover, we can do that conditionally.
For instance, in the following example we return success in case a nickname has been given to the text field when saving, and error if no nickname exists:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@State private var nickname = "" @State private var didSave = false VStack { TextField("Nickname", text: $nickname) Button("Save") { didSave = !nickname.isEmpty } } .sensoryFeedback(trigger: didSave) { oldValue, newValue in newValue ? .success : .error } |
Conditions in the closure can be as complex or as simple as necessary. The point is to return a feedback value in the end.
Note that the Save button in the example above won’t trigger a haptic feedback every time is pressed. Instead, the feedback will be triggered only when didSave actually changes.
The impact feedback
In addition to the predefined feedback styles we can choose among based on their semantic meaning, there’s also the impact feedback value. What makes it special is that it lets us customize the feedback effect, but according to documentation, it’s mostly suitable for actions that represent a physical feeling and where it feels like “UI elements colliding”.
The first variation of impact feedback gets a weight and an intensity value as arguments. The former has default values to choose from (light, medium, heavy), while intensity can get values ranging from 0.0 to 1.0:
|
1 2 3 4 5 6 7 8 9 10 11 |
@State private var trigger = false Button("Clap your hands \(Image(systemName: "hands.clap"))”) { trigger.toggle() } .sensoryFeedback( .impact(weight: .heavy, intensity: 0.8), trigger: trigger ) |
The impact feedback with the above configuration produces a somewhat strong vibration. However, weight can be replaced with flexibility in the following second variation of impact:
|
1 2 3 |
.impact(flexibility: .solid, intensity: 0.75) |
Flexibility can get any of the rigid, soft, and solid values.
More noteworthy feedback values
The above are not the only SensoryFeedback values available with the sensoryFeedback modifier; there are more available, even though they might be less commonly used. Some of them come in pair, like, for example, the start and stop feedback values, which are ideal to use in cases of timers, recordings, workout sessions, and more. For instance:
|
1 2 3 4 5 6 7 8 9 10 11 |
@State private var isRunning = false Button(isRunning ? "Stop Timer" : "Start Timer") { isRunning.toggle() } .sensoryFeedback( isRunning ? .start : .stop, trigger: isRunning ) |
In a similar fashion, we also have available the increase and decrease values, which are suitable when we need a haptic feedback to indicate a value going up or down. For instance, volume and brightness controls, zoom level, and generally any adjustable value:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@State private var brightness = 50 VStack { Text("Brightness: \(brightness)%”) HStack(spacing: 40) { Button("", systemImage: "minus.circle.fill") { guard brightness > 0 else { return } brightness -= 10 } Button("", systemImage: "plus.circle.fill") { guard brightness < 100 else { return } brightness += 10 } } } .sensoryFeedback(trigger: brightness) { oldValue, newValue in newValue > oldValue ? .increase : .decrease } |
Another interesting feedback value is the levelChange, and its name suggests what it can be used for; game levels, difficulty settings, progress stages, subscription tiers are just some of the cases where playing this feedback can be handy. Working on macOS, it produces a nice “kick” to the finger when triggered using a touch pad. For instance:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
enum Difficulty: String, CaseIterable { case easy, medium, hard } @State private var difficulty: Difficulty = .easy Picker("Difficulty", selection: $difficulty) { ForEach(Difficulty.allCases, id: \.self) { difficulty in Text(difficulty.rawValue.capitalized) } } .sensoryFeedback(.levelChange, trigger: difficulty) |
I mentioned earlier that haptic feedback is the result of a value change. Well, almost. As of iOS 26, macOS 26, and related releases, there’s also the press feedback value. It plays a haptic feedback for touch-down events on specific UI controls, specified as argument to the feedback style. See the following, for example:
|
1 2 3 4 5 6 7 8 |
@State private var totalPresses = 0 Button("Press Me") { totalPresses += 1 } .sensoryFeedback(.press(.button), trigger: totalPresses) |
Other control types that can be given as arguments are buttonIconOnly, slider, tab, toggle.
Wrapping up
SwiftUI provides native APIs to play haptic feedback, and there are several built-in, preconfigured feedback styles to use according to their semantic meaning. Those we explored in this post are the most important, but not the only ones, so I invite you to go ahead and check them all out. Just keep in mind to play haptic feedback thoughtfully, and only when it advances user experience. And on top of that, remember that the various feedback styles won’t play on all devices; trying out on actual devices is the best way to find out where a haptic feedback can actually play. I hope you found this post useful, thanks for reading!