HapticKit lets you describe a haptic once and play it on iPhone and Apple Watch. Write a pattern with a tiny result-builder DSL, or pick one of nine ready-made patterns (success, warning, error, heartbeat, countdown tick, level up, notification, typing, rattle). HapticKit compiles it to Core Haptics events and intensity curves on iPhone and to a timed sequence of WatchKit haptics on Apple Watch. A .haptic(_:trigger:) SwiftUI modifier plays it, a reduce haptics preference is respected everywhere, and a timeline view draws any pattern so you can see what you will feel.
let knock = HapticPattern("Knock") {
Tap(.sharp, intensity: 0.8)
Pause(0.08)
Rumble(duration: 0.3, intensity: .ramp(0.2...1))
}
Button("Save") { saves += 1 }
.haptic(knock, trigger: saves)Captured from the example apps on iOS 26 and watchOS 26 simulators by CI. You can't see a haptic in a screenshot, so the apps draw every pattern: taps are bars as tall as they are strong, rumbles are filled envelopes, and the color shows the sharpness, from soft (pink) to crisp (cyan).
| Pattern library | Heartbeat | Pattern builder |
|---|---|---|
![]() |
![]() |
![]() |
On Apple Watch:
| Patterns | Play |
|---|---|
![]() |
![]() |
- A tiny DSL:
HapticPattern { … }withTap,Pause,RumbleandRepeat, plusif,else,switchandfor. Patterns nest, compare, hash and areSendable. - Intensity envelopes: constant (
0.7),.ramp(0.2...1),.ramp(from: 1, to: 0),.curve([0.2, 1, 0.4])or.curve(points:)at any time. Sharpness presets from.softto.crisp, or any value. - Nine built-in patterns:
.success,.warning,.error,.heartbeat,.countdownTick,.levelUp,.notification,.typingand.rattle, all inHapticPattern.builtIns. - Core Haptics compiler:
coreHapticsDescription()turns a pattern into plain transient and continuous events andhapticIntensityControlcurves (split at 16 control points, reset after each shaped rumble). On iOSmakeHapticPattern()builds theCHHapticPattern. - WatchKit compiler:
watchHapticSequence()turns a pattern intoWKHapticTypesteps with times: soft taps click, strong taps start, rumbles pulse with their envelope, and steps the Taptic Engine would merge are dropped. Patterns with a system equivalent play it (.success,.failure,.retry,.notification…). - Engines:
HapticEngineis a small protocol.LiveHapticEngineplays on the device (aCHHapticEngineon iPhone,WKInterfaceDeviceon Apple Watch);RecordingHapticEnginerecords what was played for tests and previews. - Accessibility: pass the user's choice as a
HapticPreference(.full,.reduced,.off). Reduced patterns keep their timing at half strength, with short constant rumbles and fewer taps. - SwiftUI:
.haptic(_:trigger:)likesensoryFeedback, a closure variant that picks the pattern from the old and new value, and.hapticEngine(_:)/.hapticPreference(_:)environment values. sensoryFeedbackinterop: built-in patterns know their closestSensoryFeedback. The modifier falls back to it where custom haptics are not supported (iPad), or always withplayback: .system.- See your haptics:
HapticTimelineViewdraws taps, envelopes, a time grid and a playhead;HapticTimelineLayoutis the plain geometry behind it. - Code output:
pattern.swiftCodewrites any pattern back as DSL source, for editors and code previews. - Swift 6 strict concurrency, zero dependencies, compilation and layout tested with Swift Testing.
In Xcode choose File › Add Package Dependencies… and enter:
https://github.com/halilozel1903/HapticKit
Or add it to Package.swift:
dependencies: [
.package(url: "https://github.com/halilozel1903/HapticKit", from: "1.0.0")
]import HapticKit
import SwiftUI
struct CheckoutButton: View {
@State private var orders = 0
@State private var failures = 0
var body: some View {
Button("Place Order") { placeOrder() }
.haptic(.success, trigger: orders)
.haptic(.error, trigger: failures)
}
}Pick the pattern from the change:
Stepper("Level \(level)", value: $level)
.haptic(trigger: level) { old, new in new > old ? .levelUp : .countdownTick }Or play one yourself:
LiveHapticEngine.shared.play(.heartbeat)
LiveHapticEngine.shared.play(.heartbeat, preference: .reduced)
LiveHapticEngine.shared.stop()let doorbell = HapticPattern("Doorbell") {
Tap(.crisp, intensity: 1) // ding
Pause(0.25)
Tap(.dull, intensity: 0.7) // dong
Rumble(duration: 0.4, intensity: .ramp(from: 0.6, to: 0), sharpness: .soft)
}
let alarm = HapticPattern("Alarm") {
Repeat(4) {
Rumble(duration: 0.15, intensity: .curve([0.3, 1, 0.3]), sharpness: .sharp)
Pause(0.1)
}
if isUrgent {
HapticPattern.error // patterns nest
}
}| Element | Meaning |
|---|---|
Tap(_ sharpness: = .medium, intensity: = 1) |
A transient tap. It takes 40 ms of the timeline, so taps written back to back are felt one by one. |
Pause(_ seconds:) |
Silence. |
Rumble(duration:intensity: = 1, sharpness: = .soft) |
A continuous vibration that follows an intensity envelope. |
Repeat(_ count:) { … } |
Its steps, count times. |
HapticPattern |
Another pattern, inline. |
| Intensity | Envelope |
|---|---|
0.7 |
Constant |
.ramp(0.2...1) |
Swells from 0.2 to 1 |
.ramp(from: 1, to: 0) |
Fades out |
.curve([0.2, 1, 0.4]) |
Values spread evenly over the rumble |
.curve(points: [HapticCurvePoint(time: 0.2, value: 1), …]) |
Points at any fraction of the rumble |
Sharpness: .soft (0.1), .dull (0.3), .medium (0.5), .sharp (0.75), .crisp (1), or any value in 0...1.
A pattern is a value you can inspect:
HapticPattern.heartbeat.duration // 2.28
HapticPattern.heartbeat.events // taps and rumbles with their times
HapticPattern.heartbeat.summary // "6 taps · 3 rumbles · 2.28 s"
HapticPattern.success.swiftCode // HapticPattern("Success") { Tap(.sharp, intensity: 0.6) … }iOS has no public setting for fewer haptics, so offer one and pass it in. Every engine call and the SwiftUI modifiers respect it:
@AppStorage("reduceHaptics") private var reduceHaptics = false
var body: some View {
ContentView()
.hapticPreference(HapticPreference(reduceHaptics: reduceHaptics))
}| Preference | Plays |
|---|---|
.full |
The pattern as written |
.reduced |
Same timing, half the strength, rumbles cut to 150 ms of constant vibration, taps within 150 ms of the previous one left out |
.off |
Nothing, not even the system feedback |
pattern.reduced() and pattern.adjusted(for:) give you the adjusted pattern directly.
let description = HapticPattern.warning.coreHapticsDescription()
description.events // 2 transient taps and 1 continuous event at 0.7
description.parameterCurves // 1 hapticIntensityControl curve: 1 → 0.14, then back to 1
let pattern = try HapticPattern.warning.makeHapticPattern() // CHHapticPattern, iOS onlyHapticPattern.heartbeat.watchHapticSequence().steps
// start at 0, start at 0.22, start at 0.76, start at 0.98, start at 1.52, start at 1.74
HapticPattern.success.watchHapticSequence().steps // [.success at 0]
HapticPattern.success.watchHapticSequence(usesSystemHaptic: false).steps // [.start at 0, .start at 0.12]| Pattern step | iPhone (Core Haptics) | Apple Watch (WatchKit) |
|---|---|---|
Tap |
Transient event | .click below half strength, .start above |
Rumble, constant |
Continuous event | A pulse every 120 ms |
Rumble, shaped |
Continuous event at the peak + intensity curve | Pulses with the envelope's strength, weak ones left out |
| System equivalent | (the pattern itself) | .success, .failure, .retry, .notification, .directionUp… |
Pulses closer than 100 ms are dropped on Apple Watch because the Taptic Engine merges them; pass minimumSpacing: to change that.
Built-in patterns carry their closest system feedback:
HapticPattern.success.sensoryFeedback // .success
HapticPattern.levelUp.sensoryFeedback // .increase
HapticPattern.heartbeat.sensoryFeedback // nil
.sensoryFeedback(HapticPattern.warning.sensoryFeedback!, trigger: value)
.haptic(.warning, trigger: value, playback: .system) // the same, through HapticKit.haptic(_:trigger:) uses playback: .automatic: the custom pattern where the device supports Core Haptics, the system feedback elsewhere. HapticPattern(.selection) goes the other way and gives you a pattern for a system style.
HapticTimelineView(pattern: .heartbeat)
.frame(height: 120)
HapticTimelineView(pattern: .levelUp, progress: 0.4, showsTimeLabels: false) // with a playhead
HapticTimelineView(pattern: .typing, span: 2) // shared scaleVoiceOver reads it as "Heartbeat haptic timeline, 6 taps · 3 rumbles · 2.28 s".
@MainActor
@Test func savingPlaysSuccess() {
let engine = RecordingHapticEngine()
let model = EditorModel(haptics: engine)
model.save()
#expect(engine.played == [.success])
}#Preview {
EditorView()
.hapticEngine(RecordingHapticEngine())
}Conform to HapticEngine to route haptics anywhere else, for example to a game controller:
@MainActor
final class ControllerHaptics: HapticEngine {
var supportsHaptics: Bool { true }
func play(_ pattern: HapticPattern) { … } // pattern.coreHapticsDescription()
func stop() { … }
}The Example folder contains Haptic Playground for iPhone (the pattern library, a detail page with the timeline, the code and what each platform plays, and a pattern builder with live code) and Haptics, a standalone Apple Watch app with the pattern list and a big play button. The iPhone app has the Reduce Haptics switch in its settings section. Both use XcodeGen so no project file has to live in the repo:
brew install xcodegen
cd Example && xcodegen generate
open HapticPlayground.xcodeprojRun the HapticPlayground scheme on an iPhone and the HapticWatch scheme on an Apple Watch to feel the patterns; simulators show the timelines but play no haptics.
- Xcode 26 or later (Swift 6.2 toolchain)
- iOS 17+ (custom haptics need an iPhone 8 or later) and watchOS 10+
Issues and pull requests are welcome. Please run the tests (xcodebuild test -scheme HapticKit -destination 'platform=iOS Simulator,name=iPhone 17') before opening a PR.
HapticKit is available under the MIT license. See LICENSE.




