SwiftUI Haptics in iOS 26: sensoryFeedback, Core Haptics, and Custom AHAP Patterns
A practical guide to SwiftUI haptics in iOS 26: every .sensoryFeedback case explained, when to drop down to Core Haptics with AHAP, and the platform quirks that bite you in production.
SwiftUI haptics in iOS 26 are added by attaching the .sensoryFeedback(_:trigger:) modifier to a view and changing the trigger value when you want the Taptic Engine to fire. So a one-liner like .sensoryFeedback(.success, trigger: didSubmit) replaces the entire UIKit dance of constructing, preparing, and calling a UINotificationFeedbackGenerator. For richer textures (a long rumble that ramps up sharpness, or a haptic synced to an audio waveform), you reach for Core Haptics with CHHapticEngine and an AHAP pattern file. This guide walks both paths with the spring values, intensity numbers, and pitfalls I keep hitting in production apps.
.sensoryFeedback(_:trigger:) is the SwiftUI-native way to add haptics on iOS 17+ and iOS 26. The trigger value must be Equatable and must actually change.
iOS 26 adds .press(.button) for touch-down feedback that fires without a state change, plus tighter pairing with SF Symbol .symbolEffect motion.
Reach for CHHapticEngine + AHAP only when you need custom intensity/sharpness curves, transient + continuous blends, or audio-haptic sync. Otherwise, .sensoryFeedback wins.
iPad, Mac, and visionOS have no haptic actuators; the API compiles and silently no-ops. Always gate Core Haptics on CHHapticEngine.capabilitiesForHardware().supportsHaptics.
Apple Watch uses WKInterfaceDevice.current().play(_:) with WKHapticType; Core Haptics isn't available on watchOS.
Haptics no-op silently in Low Power Mode, when System Haptics is off in Settings, and when an active camera or dictation session pre-empts the engine.
The .sensoryFeedback modifier in 90 seconds
The whole mental model of SwiftUI haptics goes like this: declare which feedback you want, attach a value the system can watch, and mutate that value when something happens. The modifier observes the trigger with an Equatable comparison and plays the haptic on every transition. There's no prepare(), no generator instance to keep alive, no impactOccurred(). The framework handles latency, allocator reuse, and engine warm-up.
Three overloads matter day to day. The simplest plays one feedback on every change: sensoryFeedback(_:trigger:). The conditional form gates the fire with a closure: sensoryFeedback(_:trigger:condition:) takes (oldValue, newValue) -> Bool, useful when you only want a tap when crossing a threshold. The selector form returns the feedback to play (or nil to skip): sensoryFeedback(trigger:_:) with closure (oldValue, newValue) -> SensoryFeedback?. Honestly, I lean on the selector overload constantly. It lets a single modifier handle "success on completion, error on failure, nothing in between" without three separate modifiers stacking up.
Two non-obvious rules. First, the trigger has to compare different; re-assigning the same value is a no-op, so use Bool.toggle() or an incrementing UInt64 for fire-and-forget triggers. Second, the modifier reacts to any state change in its observed identity graph, so place it on the smallest view that owns the state to avoid double-fires when a parent re-renders.
Every SensoryFeedback case (and what each one feels like)
I keep this table on a sticky note next to my desk because the naming alone doesn't tell you the texture. Test on a real device. The simulator has no Taptic Engine and these are silent there.
Case
Feel
Use it for
.success
Two crisp taps, rising
Form submit, purchase confirmed
.warning
Two taps, falling
Unsaved changes, soft block
.error
Three sharp taps
Hard failure, validation rejection
.selection
Single light tick
Picker scroll, slider step, segmented control
.impact(weight: .light)
Soft thud
Toggle on, chip select
.impact(weight: .medium)
Medium thud
Default for taps that "land"
.impact(weight: .heavy)
Solid thump
Card snap-into-place, big commit
.impact(flexibility: .rigid, intensity: 0.8)
Tight crack
Button-down on a hard surface
.impact(flexibility: .soft, intensity: 0.5)
Pillow tap
Gentle UI state changes
.start / .stop
Asymmetric pair
Recording, timer, scanner sessions
.alignment
Tick at edge
Cropping, snapping to a guide
.levelChange
Trackpad pressure pop
Mac force-click
.increase / .decrease
Pitched ticks
Stepper, brightness slider
.press(.button)(iOS 26)
Touch-down tap
Long-press affordance, key press
The two .impact initializers aren't redundant. The weight form maps to the classic UIImpactFeedbackGenerator.FeedbackStyle (light/medium/heavy), while the flexibility form maps to the newer texture-based generators (.rigid, .soft, .solid). Intensity scales from 0.0 to 1.0 and I almost never push it above 0.85. Anything closer to 1.0 reads as aggressive on a quiet UI moment.
How do I add haptic feedback to a SwiftUI Button?
The shortest correct answer: attach .sensoryFeedback to the button (or its enclosing view) and bind it to a trigger value that mutates inside the action closure. If you want feedback only on success, gate it with a condition closure or the selector overload. Here's the pattern I ship, paired with a .symbolEffect(.bounce, value:) so the icon motion and the haptic peak at the same instant.
struct SaveButton: View {
@State private var saveCount = 0
@State private var lastResult: SaveResult = .idle
enum SaveResult: Equatable { case idle, success, failure }
var body: some View {
Button {
saveCount += 1
lastResult = performSave() ? .success : .failure
} label: {
Label("Save", systemImage: "checkmark.circle.fill")
}
.symbolEffect(.bounce, value: saveCount)
.sensoryFeedback(trigger: lastResult) { _, new in
switch new {
case .success: return .success
case .failure: return .error
case .idle: return nil
}
}
}
}
For a plain "tap registered" feel (no success/failure branching) use a single Bool that toggles, or a counter that increments. Don't use true as a trigger; toggling between true and true is a no-op. The spring response 0.4 with damping 0.7 I default to for button press animations pairs cleanly with a .impact(weight: .light, intensity: 0.6). Try matching the spring's settle time to the haptic so the user's finger leaves the screen exactly when the bump arrives.
One more shape: long-press confirmation. iOS 26's .press(.button) case fires on touch-down so the user knows the press is registering before the threshold. Pair it with a .success on completion and a .warning if they release early.
sensoryFeedback vs UIImpactFeedbackGenerator
I get asked this every code review. .sensoryFeedback is a thin SwiftUI wrapper over the same UIKit generators, so the physical sensation is identical for matching cases. What differs is the lifecycle.
Dimension
.sensoryFeedback
UIImpactFeedbackGenerator
CHHapticEngine
Paradigm
Declarative, value-driven
Imperative
Imperative + pattern-based
Setup
None
prepare() recommended
start(), handlers, AHAP load
Min iOS
17 (selector overload: 17)
10
13
Custom intensity curves
No
No
Yes (parameter curves)
Audio-haptic sync
No
No
Yes (AudioCustom)
Best for
UI state changes
Legacy code, UIKit
Games, immersive UX
If your project still has a SwiftUI/UIKit hybrid, you can mix freely. Firing a UIImpactFeedbackGenerator from inside a SwiftUI action is fine. The reason to migrate to .sensoryFeedback isn't physical, it's structural: the modifier scopes the haptic to the view's identity. When the view goes off-screen the system stops watching. With UIImpactFeedbackGenerator you have to remember to nil the generator and avoid leaks across UIViewController transitions. I've shipped those leaks, and so has every team I've reviewed.
Custom haptic patterns with Core Haptics and AHAP
Once you need a continuous rumble, a non-uniform intensity ramp, or a haptic that locks to an audio file, drop down to Core Haptics and the AHAP format. AHAP is JSON with two top-level keys, Version and Pattern. Each pattern entry is an Event with a Time (seconds from the start), an EventType (HapticTransient, HapticContinuous, AudioContinuous, or AudioCustom), and event parameters.
Two parameters do most of the work. HapticIntensity (0.0 to 1.0) controls how strongly the actuator drives. HapticSharpness (0.0 to 1.0) controls timbre: 0.0 feels organic and dull, 1.0 feels mechanical and crisp. I use sharpness 0.2 for "natural" feedback (a card landing, a marble rolling) and sharpness 0.9 for UI confirmation (a button clicking down). A two-event "click and release" pattern:
Three details I learned the hard way. First, set engine.playsHapticsOnly = true when you're not syncing audio, because Core Haptics otherwise routes through the audio graph and adds latency. Second, always assign both stoppedHandler and resetHandler and restart the engine inside them; phone calls, Siri, and Reachability events stop the engine and the framework does not auto-restart. Third, playPattern(from:) blocks while loading the file. If you're triggering on tap-down, load the pattern once at view appear and play a cached CHHapticPatternPlayer on tap. I hit this exact bug shipping a drum pad app, and it ate about a day before I figured out the load was the culprit.
For programmatic patterns, build CHHapticEvent arrays in code and feed them to CHHapticPattern. Use CHHapticParameterCurve with controlPoints at varying relativeTime values to ramp intensity over a continuous event. That's how you get a "spin up to peak then fade" rumble. Apple's Introducing Core Haptics session is the canonical reference for the curve math.
Why is .sensoryFeedback not working?
Five causes account for 95% of "haptics aren't firing" tickets I've triaged:
You're on the simulator. No simulator has a Taptic Engine. .sensoryFeedback, UIImpactFeedbackGenerator, and CHHapticEngine all silently no-op. Test on hardware.
You're on iPad, Mac, or visionOS. None of these have haptic actuators. The API still compiles. This is intentional. Apple wants you to write one code path.
The trigger didn't actually change. If trigger: someBool is assigned true while it's already true, no change, no fire. Toggle it (someBool.toggle()) or use a counter.
System Haptics is disabled. Settings → Sounds & Haptics → System Haptics. Off = no fire, no warning.
Low Power Mode is on. iOS silences the Taptic Engine to save battery. There's no override and no callback.
One subtler case: an active AVCaptureSession or speech recognition session pre-empts the Taptic Engine. If your app records video and you want haptics during recording, call AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true). For Core Haptics specifically, also check CHHapticEngine.capabilitiesForHardware().supportsHaptics and bail early on unsupported devices. Never assume.
The matrix matters for any app that ships to more than one platform. The SensoryFeedback documentation lists each platform's behavior but it's spread across pages. Here's the consolidated version I use for cross-platform sanity checks.
iPhone is the full target: every case fires, Core Haptics works, AHAP files play. iPad has no haptic hardware on any shipping model in 2026; the APIs compile so you don't need #if os(iOS) noise, but nothing plays. Mac has no haptic actuators outside the trackpad's force-touch motor; .levelChange is the trackpad-specific case and the others no-op. Apple Watch uses an entirely different API, WKInterfaceDevice.current().play(_:) with a WKHapticType case like .notification, .click, .start, or .directionUp. Critically, Core Haptics is not available on watchOS: no CHHapticEngine, no AHAP. visionOS has no haptic actuators.
Here's a one-liner safety check before any Core Haptics work:
For .sensoryFeedback you don't need to gate; the system handles unsupported platforms internally. The reason to keep the API call in shared code (rather than ifdef'ing it out) is that it documents intent. A future iPad model with haptic hardware will Just Work without code changes.
Accessibility and Reduce Motion
Haptics intersect with accessibility in three ways that most tutorials skip. First, never use a haptic as the sole confirmation channel; pair every haptic with a visible state change so VoiceOver users get the signal. Second, the system "Reduce Motion" setting doesn't silence haptics by default, but Apple's Human Interface Guidelines recommend toning them down. Read UIAccessibility.isReduceMotionEnabled and switch to .selection instead of .impact(weight: .heavy). Third, the "Haptic Touch" timing setting in Settings → Accessibility → Touch affects how long-press confirmations feel, so use .press(.button) as the touch-down cue. That way users who set slow timing still know the press registered.
This dovetails with the broader SwiftUI accessibility philosophy: every output should reach every user, and haptics are one channel among several rather than a stand-alone language. When you ship a "buzz on error" pattern, also ship the red border, the announce-to-VoiceOver, and the focus shift to the offending field. The buzz reinforces; it does not narrate.
Pairing haptics with SF Symbol effects
The Apple Human Interface Guidelines in 2026 frame haptics as reinforcement, not narration. The pattern I use everywhere: pair a transient haptic with the keyframe peak of a .symbolEffect motion. A .bounce.down on a checkmark hits its bottom at roughly the 60% mark of its 0.5-second timeline, so firing .success on the same value change makes the bump land at the deepest part of the bounce. The user feels and sees the same beat.
Long-press confirm: .scale.up over 0.4s + .press(.button) at start, .success at end
For deeper motion design context, the SwiftUI gesture composition guide pairs well with this one, since gestures are the input side of the same conversation. The general rule: motion answers "what happened visually?" and haptics answer "did the device feel it?" When you skip the haptic on a motion that the user initiated with a tap, the interaction feels uncannily silent. When you fire a haptic without any motion, the user looks for what they missed. Match them.
Frequently Asked Questions
How do I create a custom haptic pattern in Swift?
Build an AHAP JSON file with HapticTransient and HapticContinuous events, drop it in your bundle, and play it with CHHapticEngine.playPattern(from: url). For programmatic patterns, construct CHHapticEvent values in code with hapticIntensity and hapticSharpness parameters and wrap them in a CHHapticPattern.
Does Apple Watch support Core Haptics?
No. watchOS has its own API: WKInterfaceDevice.current().play(_:) with a WKHapticType case like .notification, .click, or .directionUp. CHHapticEngine is iOS-only. Wrap your watch haptics in a separate helper rather than trying to share code with iPhone Core Haptics paths.
How do I sync audio and haptics in iOS?
Use Core Haptics with an AHAP file that includes AudioCustom events alongside HapticTransient/HapticContinuous events. The EventWaveformPath parameter points to a bundled audio file, and the haptic timeline plays in lockstep with the audio. Set engine.playsHapticsOnly = false so the engine routes through the audio graph.
Why doesn't .sensoryFeedback work in the SwiftUI preview?
Previews and the iOS Simulator have no Taptic Engine, so every haptic API silently no-ops. Test on a physical iPhone. If it also fails on hardware, check that the trigger value actually changes between fires, that System Haptics is enabled in Settings, and that Low Power Mode is off.
What is the difference between HapticIntensity and HapticSharpness?
Intensity controls how strongly the Taptic Engine drives: 0.0 is silent, 1.0 is maximum amplitude. Sharpness controls the timbre: 0.0 feels organic and dull (a marble rolling), 1.0 feels mechanical and crisp (a button click). Both range 0.0 to 1.0 and can be ramped over time with CHHapticParameterCurve.
How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.
Swift RegexBuilder gives you type-safe regular expressions with named captures, inline transforms, and Unicode-correct matching by default. Learn the DSL, compare it to NSRegularExpression, and avoid production traps.
Build SwiftUI Metal shaders in iOS 26 with colorEffect, distortionEffect, and layerEffect. Includes MSL setup, TimelineView animation, and GPU cost tips with working code.