Every iOS Haptic Pattern, With Code

By Alok Singh · Published

iOS gives you three layers of haptic API:

  1. UIKit feedback generators. These are the system's built-in patterns (impact, selection, notification and canvas). They take little code, match the rest of the OS, and are what you should reach for first.
  2. SwiftUI .sensoryFeedback. This is a declarative wrapper over the same system patterns, triggered by state changes (iOS 17+).
  3. Core Haptics. Build fully custom patterns from transient "taps" and continuous "buzzes", change them in real time, and load them from AHAP files (iOS 13+).

This guide covers every pattern in each layer with code you can paste into a project, followed by best practices and device-support notes. Haptics are hard to judge from a description, so it helps to play each one on a real iPhone as you read. The Simulator plays nothing.

Try them on your phone: Haptics Explorer (free, by the author of this guide) plays every pattern below on your device. You can adjust intensity and sharpness, then copy the matching UIKit or SwiftUI code.


Contents

  1. Quick reference table
  2. UIImpactFeedbackGenerator: all five styles
  3. UISelectionFeedbackGenerator
  4. UINotificationFeedbackGenerator: success, warning, error
  5. UICanvasFeedbackGenerator (iPad + Apple Pencil Pro)
  6. The iOS 17.5 view-based initializers
  7. SwiftUI .sensoryFeedback
  8. Core Haptics: engine, transient & continuous events
  9. Dynamic parameters and parameter curves
  10. AHAP files
  11. Device support and when haptics are silent
  12. Best practices

1. Quick reference

Pattern UIKit SwiftUI (iOS 17+) Typical use
Light impact UIImpactFeedbackGenerator(style: .light) .impact(weight: .light) Small UI elements snapping into place
Medium impact .medium .impact(weight: .medium) / .impact General "thud"
Heavy impact .heavy .impact(weight: .heavy) Large objects colliding
Soft impact .soft (iOS 13+) .impact(flexibility: .soft) Collisions with something squishy
Rigid impact .rigid (iOS 13+) .impact(flexibility: .rigid) Hard, crisp collisions
Selection tick UISelectionFeedbackGenerator .selection Picker wheels, segmented values changing
Success UINotificationFeedbackGenerator, .success .success Task finished
Warning .warning .warning Task produced a warning
Error .error .error Task failed
Canvas alignment UICanvasFeedbackGenerator.alignmentOccurred(at:) (iOS 17.5) .alignment Shape snaps to a guide
Path complete UICanvasFeedbackGenerator.pathCompleted(at:) (iOS 17.5) .pathComplete (iOS 17.5) Drawn shape recognized
Custom Core Haptics n/a (wrap Core Haptics) Anything else: heartbeats, rumbles, game effects

2. UIImpactFeedbackGenerator: all five styles

Impact feedback simulates a physical collision. FeedbackStyle describes the mass (light, medium, heavy) or material (soft, rigid) of the objects that collide.

import UIKit

final class ImpactDemoViewController: UIViewController {

    // Keep a reference so you can call prepare() ahead of time.
    private lazy var impact = UIImpactFeedbackGenerator(style: .medium, view: view) // iOS 17.5+

    @objc func cardDidSnap(_ sender: UIPanGestureRecognizer) {
        // Fire at the touch location so the system can route the haptic
        // to the right device (e.g. Apple Pencil Pro) where applicable.
        impact.impactOccurred(at: sender.location(in: view))
    }

    @objc func dragBegan() {
        // Wake the Taptic Engine a moment *before* you need it.
        impact.prepare()
    }
}

All five styles:

let styles: [UIImpactFeedbackGenerator.FeedbackStyle] = [.light, .medium, .heavy, .soft, .rigid]

for style in styles {
    let generator = UIImpactFeedbackGenerator(style: style, view: view)
    generator.impactOccurred()
}

Variable intensity (iOS 13+)

impactOccurred(intensity:) takes a value from 0.0 to 1.0. It lets one generator express "harder" and "softer" hits. A slider that bumps harder as it nears its end stop is a good example:

let generator = UIImpactFeedbackGenerator(style: .rigid, view: view)
generator.impactOccurred(intensity: 0.4)              // gentle
generator.impactOccurred(intensity: 1.0, at: point)   // full strength at a location (iOS 17.5+)

Before iOS 17.5: use UIImpactFeedbackGenerator(style:) with no view. Apple's documentation lists the view-less initializers (UIFeedbackGenerator.init() and UIImpactFeedbackGenerator.init(style:)) as deprecated in iOS 27, so new code should prefer the view: versions when it can. See section 6.


3. UISelectionFeedbackGenerator

Selection feedback is a light "tick" that marks movement through discrete values, like the wheels of UIDatePicker. Apple's guidance is to play it when the selection changes, not when the user confirms a choice.

final class RatingControl: UIControl {
    private lazy var selection = UISelectionFeedbackGenerator(view: self) // iOS 17.5+

    var value = 0 {
        didSet {
            guard value != oldValue else { return }
            selection.selectionChanged()
            selection.prepare()   // more ticks are likely; stay warm
        }
    }

    override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        selection.prepare()
        return true
    }
}

With a location (iOS 17.5+): selection.selectionChanged(at: touch.location(in: self)).


4. UINotificationFeedbackGenerator: success, warning, error

These three patterns tell the user how a task turned out. Each has a distinct rhythm that users learn quickly, so use them only for real outcomes.

let notifier = UINotificationFeedbackGenerator(view: view) // iOS 17.5+

func saveFinished(_ result: Result<Void, Error>) {
    switch result {
    case .success:
        notifier.notificationOccurred(.success)
    case .failure(let error as ValidationError):
        notifier.notificationOccurred(.warning)
        show(error)
    case .failure(let error):
        notifier.notificationOccurred(.error)
        show(error)
    }
}

FeedbackType has exactly three cases: .success, .warning and .error. iOS 17.5 adds notificationOccurred(_:at:) for location-aware feedback.

Tip: success and warning can feel similar on first try. Play the three back to back (Haptics Explorer has a "Tell them apart" mode for exactly this) before you decide which one a flow deserves.


5. UICanvasFeedbackGenerator (iOS 17.5+)

This generator was added for drawing canvases. On a compatible iPad with Apple Pencil Pro, its feedback plays in the pencil itself.

final class CanvasViewController: UIViewController {
    private lazy var canvasFeedback = UICanvasFeedbackGenerator(view: view)

    @objc func dragShape(_ pan: UIPanGestureRecognizer) {
        if pan.state == .began { canvasFeedback.prepare() }
        // ...move the shape...
        if shapeIsAlignedWithGuide {
            canvasFeedback.alignmentOccurred(at: pan.location(in: view))
        }
    }

    func shapeRecognized(at point: CGPoint) {
        canvasFeedback.pathCompleted(at: point)
    }
}

6. The iOS 17.5 view-based initializers

iOS 17.5 changed every generator so that it can be attached to a view (init(view:), init(style:view:)), and added …(at: CGPoint) variants of each trigger method. Two reasons:

If you support releases older than 17.5, wrap the creation:

@MainActor
func makeImpactGenerator(style: UIImpactFeedbackGenerator.FeedbackStyle,
                         in view: UIView) -> UIImpactFeedbackGenerator {
    if #available(iOS 17.5, *) {
        return UIImpactFeedbackGenerator(style: style, view: view)
    } else {
        return UIImpactFeedbackGenerator(style: style)
    }
}

7. SwiftUI .sensoryFeedback (iOS 17+)

SwiftUI plays feedback when a trigger value changes. There are three forms of the modifier:

import SwiftUI

struct CheckoutButton: View {
    @State private var orderPlaced = 0
    @State private var quantity = 1
    @State private var lastResult: Bool?

    var body: some View {
        VStack(spacing: 24) {

            // 1. Fixed feedback whenever the trigger changes.
            Button("Place order") { orderPlaced += 1 }
                .sensoryFeedback(.success, trigger: orderPlaced)

            // 2. Fixed feedback, but only when the condition returns true.
            Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)
                .sensoryFeedback(.impact(weight: .heavy), trigger: quantity) { old, new in
                    new == 10 || new == 1          // bump only at the limits
                }

            // 3. Choose the feedback (or none) from the old and new values.
            Text(lastResult.map { $0 ? "Saved" : "Failed" } ?? "")
                .sensoryFeedback(trigger: lastResult) { _, new in
                    switch new {
                    case true?:  return .success
                    case false?: return .error
                    case nil:    return nil        // no feedback
                    }
                }
        }
    }
}

Every SensoryFeedback value, and where it actually plays

The type is available on every platform, but Apple's documentation is explicit that many cases only play on some of them. This is the most common source of "my haptic doesn't work" bugs:

SensoryFeedback Plays on (per Apple's docs) Notes
.success, .warning, .error iOS, watchOS Same as UINotificationFeedbackGenerator
.selection iOS, watchOS Same as UISelectionFeedbackGenerator
.impact, .impact(weight:intensity:), .impact(flexibility:intensity:) iOS, watchOS weight: .light / .medium / .heavy; flexibility: .rigid / .solid / .soft; intensity 0–1
.alignment iOS, macOS Dragging into alignment
.pathComplete (iOS 17.5+) iOS Drawn path recognized
.levelChange macOS Force Touch pressure levels
.increase, .decrease watchOS, visionOS Will not play on iPhone
.start, .stop watchOS Will not play on iPhone

iOS 26 adds control-specific variants: .press(_:) (for example .button, .buttonIconOnly, .slider, .tab, .toggle), .release(_:) (.slider), and .selection(_:) (.minimum, .maximum, .on, .off). Gate them with if #available(iOS 26, *), and test on hardware, because platform behavior can differ.

Custom patterns in SwiftUI

.sensoryFeedback can only play system patterns. For anything custom, call Core Haptics (next section) from an onChange:

.onChange(of: heartbeatTrigger) { _, _ in
    HapticPlayer.shared.playHeartbeat()
}

8. Core Haptics (iOS 13+)

Core Haptics gives you direct control of the Taptic Engine. A pattern is a list of events placed on a timeline:

Each event has two main parameters:

A reusable engine wrapper

import CoreHaptics

@MainActor
final class HapticPlayer {
    static let shared = HapticPlayer()

    private var engine: CHHapticEngine?
    let supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics

    private init() { createEngine() }

    private func createEngine() {
        guard supportsHaptics else { return }
        do {
            let engine = try CHHapticEngine()
            engine.playsHapticsOnly = true          // no audio events → lower latency
            engine.isAutoShutdownEnabled = true     // let the system idle the engine

            // The engine can stop (e.g. app backgrounded, audio session interrupted).
            engine.stoppedHandler = { reason in
                print("Haptic engine stopped: \(reason)")
            }
            // The haptic server can reset; restart the engine and recreate players.
            engine.resetHandler = { [weak self] in
                Task { @MainActor in try? self?.engine?.start() }
            }
            try engine.start()
            self.engine = engine
        } catch {
            print("Haptic engine creation failed: \(error)")
        }
    }

    func play(_ pattern: CHHapticPattern) {
        guard let engine else { return }
        do {
            try engine.start()                      // cheap if already running
            let player = try engine.makePlayer(with: pattern)
            try player.start(atTime: CHHapticTimeImmediate)
        } catch {
            print("Failed to play pattern: \(error)")
        }
    }
}

Transient event: a single tap

func tapPattern(intensity: Float = 1.0, sharpness: Float = 0.5) throws -> CHHapticPattern {
    let tap = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
        ],
        relativeTime: 0
    )
    return try CHHapticPattern(events: [tap], parameters: [])
}

Continuous event: a buzz

func buzzPattern(duration: TimeInterval = 0.6) throws -> CHHapticPattern {
    let buzz = CHHapticEvent(
        eventType: .hapticContinuous,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3),
            CHHapticEventParameter(parameterID: .attackTime, value: 0.1),   // fade in
            CHHapticEventParameter(parameterID: .releaseTime, value: 0.2)   // fade out
        ],
        relativeTime: 0,
        duration: duration
    )
    return try CHHapticPattern(events: [buzz], parameters: [])
}

Other event parameters are .decayTime and .sustained. .sustained is a Boolean (0 or 1) that decides whether the event holds for its full specified duration.

Combining events: heartbeat, double tap, tick-tock

extension CHHapticEvent {
    static func tap(_ intensity: Float, _ sharpness: Float, at time: TimeInterval) -> CHHapticEvent {
        CHHapticEvent(eventType: .hapticTransient,
                      parameters: [.init(parameterID: .hapticIntensity, value: intensity),
                                   .init(parameterID: .hapticSharpness, value: sharpness)],
                      relativeTime: time)
    }
}

/// "lub-dub": a strong beat followed by a softer one
func heartbeat() throws -> CHHapticPattern {
    try CHHapticPattern(events: [.tap(1.0, 0.3, at: 0.0),
                                 .tap(0.6, 0.2, at: 0.15)],
                        parameters: [])
}

func doubleTap() throws -> CHHapticPattern {
    try CHHapticPattern(events: [.tap(0.9, 0.7, at: 0.0),
                                 .tap(0.9, 0.7, at: 0.1)],
                        parameters: [])
}

func tickTock(beats: Int = 4) throws -> CHHapticPattern {
    let events = (0..<beats).map { i in
        CHHapticEvent.tap(i.isMultiple(of: 2) ? 0.8 : 0.5,
                          i.isMultiple(of: 2) ? 0.9 : 0.4,
                          at: Double(i) * 0.5)
    }
    return try CHHapticPattern(events: events, parameters: [])
}

// Usage
if let pattern = try? heartbeat() { HapticPlayer.shared.play(pattern) }

Looping, pausing and seeking: advanced players

makeAdvancedPlayer(with:) returns a CHHapticAdvancedPatternPlayer, which supports loopEnabled, loopEnd, playbackRate, pause(atTime:), resume(atTime:) and seek(toOffset:):

let player = try engine.makeAdvancedPlayer(with: try heartbeat())
player.loopEnabled = true
player.loopEnd = 0.8          // seconds; one beat cycle
player.playbackRate = 1.2     // faster heartbeat
try player.start(atTime: CHHapticTimeImmediate)
// later…
try player.stop(atTime: CHHapticTimeImmediate)

9. Dynamic parameters and parameter curves

You can change a pattern while it plays. Two tools do this:

Dynamic parameters are one-off changes sent to a running player, such as following a drag gesture:

func updateRumble(_ player: CHHapticAdvancedPatternPlayer, strength: Float) throws {
    let intensity = CHHapticDynamicParameter(parameterID: .hapticIntensityControl,
                                             value: strength, relativeTime: 0)
    let sharpness = CHHapticDynamicParameter(parameterID: .hapticSharpnessControl,
                                             value: strength * 0.5, relativeTime: 0)
    try player.sendParameters([intensity, sharpness], atTime: CHHapticTimeImmediate)
}

Parameter curves are pre-planned envelopes baked into the pattern. This one is a "power up" that swells and then fades:

func powerUp() throws -> CHHapticPattern {
    let rumble = CHHapticEvent(
        eventType: .hapticContinuous,
        parameters: [.init(parameterID: .hapticIntensity, value: 1.0),
                     .init(parameterID: .hapticSharpness, value: 0.4)],
        relativeTime: 0, duration: 1.0)

    let curve = CHHapticParameterCurve(
        parameterID: .hapticIntensityControl,
        controlPoints: [
            .init(relativeTime: 0.0, value: 0.2),
            .init(relativeTime: 0.7, value: 1.0),
            .init(relativeTime: 1.0, value: 0.0)
        ],
        relativeTime: 0)

    return try CHHapticPattern(events: [rumble, .tap(1.0, 1.0, at: 1.0)],
                               parameterCurves: [curve])
}

The control parameters (.hapticIntensityControl, .hapticSharpnessControl) modulate the event's own values; they don't replace them. Intensity control scales the event's intensity, and sharpness control shifts its sharpness. That's why the event above starts at full intensity and the curve shapes it.


10. AHAP files

AHAP (Apple Haptic and Audio Pattern) is a JSON file format that describes a Core Haptics pattern. Keeping patterns in files means designers can tune them without code changes, and the same file can be shared across apps.

Heartbeat.ahap:

{
  "Version": 1.0,
  "Metadata": { "Project": "Demo", "Description": "Lub-dub heartbeat" },
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.3 }
        ]
      }
    },
    {
      "Event": {
        "Time": 0.15,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.2 }
        ]
      }
    },
    {
      "Event": {
        "Time": 0.3,
        "EventType": "HapticContinuous",
        "EventDuration": 0.4,
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.3 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.1 }
        ]
      }
    },
    {
      "ParameterCurve": {
        "ParameterID": "HapticIntensityControl",
        "Time": 0.3,
        "ParameterCurveControlPoints": [
          { "Time": 0.0, "ParameterValue": 1.0 },
          { "Time": 0.4, "ParameterValue": 0.0 }
        ]
      }
    }
  ]
}

Play it straight from the bundle:

func playAHAP(named name: String, engine: CHHapticEngine) {
    guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else { return }
    do {
        try engine.start()
        try engine.playPattern(from: url)
    } catch {
        print("AHAP playback failed: \(error)")
    }
}

On iOS 16 and later you can load it as a CHHapticPattern instead, which lets you use an advanced player for looping and seeking:

let pattern = try CHHapticPattern(contentsOf: url)   // iOS 16+
let player = try engine.makeAdvancedPlayer(with: pattern)

AHAP files can also include audio events (AudioCustom, AudioContinuous). If you set engine.playsHapticsOnly = true, those are ignored.

Workflow tip: design the feel on a device first, then export. Haptics Explorer imports AHAP files for free; exporting AHAP is part of its Premium tier. Its generated Core Haptics code is labeled as an AHAP approximation of the system patterns, because UIKit's built-in patterns aren't published as AHAP.


11. Device support, and when haptics are silent

Situation Behavior
iPhone 8 or later (including iPhone SE 2nd gen and later) Full support: UIKit generators and Core Haptics
iPhone 7 / 7 Plus UIKit feedback generators work; Core Haptics reports supportsHaptics == false
iPad No Taptic Engine for these APIs; generators do nothing and Core Haptics reports no support. Exception: on a compatible iPad, UICanvasFeedbackGenerator feedback can play on Apple Pencil Pro (iOS 17.5+)
Simulator Never plays haptics. Always test on a device
User turned off Settings › Sounds & Haptics › System Haptics UIKit feedback generators are silent
App is recording audio with AVAudioSession System haptics are suppressed by default; opt in with try AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true) (iOS 13+)
App in background Core Haptics engines stop; restart in stoppedHandler/resetHandler or on the next play

Always guard Core Haptics with CHHapticEngine.capabilitiesForHardware().supportsHaptics, and treat haptics as an enhancement: the UI must make sense without them.


12. Best practices

  1. Prefer system patterns. Users already know what success, error and selection feel like. Custom patterns are for moments the system doesn't cover, such as games, rhythms and physical metaphors.
  2. Be consistent. Map one meaning to one pattern across the whole app, and never use .error for something that isn't an error.
  3. Pair haptics with visuals and audio. A haptic should confirm something the user can also see; it shouldn't be the only signal.
  4. Use them sparingly. If everything buzzes, nothing stands out, and the battery takes the hit.
  5. Call prepare() early, not immediately before triggering. Prepare at touch-down or gesture-began. Preparing and then firing straight away doesn't reduce latency.
  6. Keep generators alive for the interaction and release them afterwards so the Taptic Engine can go idle.
  7. Match the strength to the visual. A tiny toggle shouldn't feel like a heavy impact, and an animation and its haptic should peak at the same moment.
  8. Respect user settings and accessibility. Don't try to work around System Haptics being off. Offer an in-app haptics toggle for custom Core Haptics effects.
  9. Test on several devices. The same intensity can feel different across iPhone generations.
  10. Prototype by feel. Read the numbers, then pick what feels right on a real phone. That's the main reason we built Haptics Explorer: compare patterns side by side, loop one for a long test, and copy the Swift once it feels right.

Further reading (Apple)

Written by Alok Singh, iOS engineer and developer of Haptics Explorer. Found a mistake? Email aryansbtloe.business@gmail.com.


← All developer guides