Every iOS Haptic Pattern, With Code
iOS gives you three layers of haptic API:
- 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.
- SwiftUI
.sensoryFeedback. This is a declarative wrapper over the same system patterns, triggered by state changes (iOS 17+). - 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
- Quick reference table
- UIImpactFeedbackGenerator: all five styles
- UISelectionFeedbackGenerator
- UINotificationFeedbackGenerator: success, warning, error
- UICanvasFeedbackGenerator (iPad + Apple Pencil Pro)
- The iOS 17.5 view-based initializers
- SwiftUI
.sensoryFeedback - Core Haptics: engine, transient & continuous events
- Dynamic parameters and parameter curves
- AHAP files
- Device support and when haptics are silent
- 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()andUIImpactFeedbackGenerator.init(style:)) as deprecated in iOS 27, so new code should prefer theview: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:
- The system can work out which input device the interaction came from (finger or Apple Pencil Pro) and play the haptic in the right place.
- The generator's lifetime is tied to UI that actually exists on screen.
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:
- Transient: a short, sharp tap. You can't set its duration.
- Continuous: a sustained vibration with a duration (up to 30 seconds per event).
Each event has two main parameters:
- Intensity (
0…1): how strong. - Sharpness (
0…1): how it feels, from round and dull (0) to crisp and precise (1).
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
- 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.
- Be consistent. Map one meaning to one pattern across the whole app, and never use
.errorfor something that isn't an error. - Pair haptics with visuals and audio. A haptic should confirm something the user can also see; it shouldn't be the only signal.
- Use them sparingly. If everything buzzes, nothing stands out, and the battery takes the hit.
- Call
prepare()early, not immediately before triggering. Prepare at touch-down or gesture-began. Preparing and then firing straight away doesn't reduce latency. - Keep generators alive for the interaction and release them afterwards so the Taptic Engine can go idle.
- 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.
- 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.
- Test on several devices. The same intensity can feel different across iPhone generations.
- 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)
- Playing haptic feedback in your app (UIKit generators & Apple Pencil Pro)
- UIFeedbackGenerator
- SensoryFeedback
- Core Haptics
- Representing haptic patterns in AHAP files
- Human Interface Guidelines: Playing haptics
Written by Alok Singh, iOS engineer and developer of Haptics Explorer. Found a mistake? Email aryansbtloe.business@gmail.com.