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 iOS 26 Guide (2026)

Updated: June 26, 2026

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.

import SwiftUI

struct LikeButton: View {
    @State private var likeCount = 0

    var body: some View {
        Button("Like (\(likeCount))") { likeCount += 1 }
            .sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: likeCount)
    }
}

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.

CaseFeelUse it for
.successTwo crisp taps, risingForm submit, purchase confirmed
.warningTwo taps, fallingUnsaved changes, soft block
.errorThree sharp tapsHard failure, validation rejection
.selectionSingle light tickPicker scroll, slider step, segmented control
.impact(weight: .light)Soft thudToggle on, chip select
.impact(weight: .medium)Medium thudDefault for taps that "land"
.impact(weight: .heavy)Solid thumpCard snap-into-place, big commit
.impact(flexibility: .rigid, intensity: 0.8)Tight crackButton-down on a hard surface
.impact(flexibility: .soft, intensity: 0.5)Pillow tapGentle UI state changes
.start / .stopAsymmetric pairRecording, timer, scanner sessions
.alignmentTick at edgeCropping, snapping to a guide
.levelChangeTrackpad pressure popMac force-click
.increase / .decreasePitched ticksStepper, brightness slider
.press(.button) (iOS 26)Touch-down tapLong-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.sensoryFeedbackUIImpactFeedbackGeneratorCHHapticEngine
ParadigmDeclarative, value-drivenImperativeImperative + pattern-based
SetupNoneprepare() recommendedstart(), handlers, AHAP load
Min iOS17 (selector overload: 17)1013
Custom intensity curvesNoNoYes (parameter curves)
Audio-haptic syncNoNoYes (AudioCustom)
Best forUI state changesLegacy code, UIKitGames, 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:

{
  "Version": 1.0,
  "Pattern": [
    { "Event": {
        "Time": 0.0, "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.85 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.95 }
        ] } },
    { "Event": {
        "Time": 0.12, "EventType": "HapticContinuous",
        "EventDuration": 0.25,
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.4 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.2 }
        ] } }
  ]
}

Load and play it from SwiftUI:

import CoreHaptics
import SwiftUI

@Observable
final class HapticPlayer {
    private var engine: CHHapticEngine?
    private(set) var supported = false

    init() {
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
        supported = true
        do {
            engine = try CHHapticEngine()
            engine?.playsHapticsOnly = true
            engine?.stoppedHandler = { [weak self] _ in try? self?.engine?.start() }
            engine?.resetHandler   = { [weak self] in   try? self?.engine?.start() }
            try engine?.start()
        } catch {
            supported = false
        }
    }

    func play(pattern name: String) {
        guard supported,
              let url = Bundle.main.url(forResource: name, withExtension: "ahap")
        else { return }
        do {
            try engine?.playPattern(from: url)
        } catch {
            try? engine?.start()
        }
    }
}

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:

  1. You're on the simulator. No simulator has a Taptic Engine. .sensoryFeedback, UIImpactFeedbackGenerator, and CHHapticEngine all silently no-op. Test on hardware.
  2. 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.
  3. 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.
  4. System Haptics is disabled. Settings → Sounds & Haptics → System Haptics. Off = no fire, no warning.
  5. 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.

Platform support: iPhone, iPad, Mac, Watch, Vision

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:

guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }

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.

Practical pairings I ship:

  • Heart button: .bounce + .impact(weight: .light, intensity: 0.7)
  • Toggle on: .replace.upUp + .selection
  • Refresh complete: .rotate + .success
  • Error shake: custom .offset keyframe + .error
  • 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.

Diana Kowalski
About the Author Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.