Haptic
Native haptic feedback — a tap you feel rather than see. The web side asks for a feedback type; iOS plays it through UINotificationFeedbackGenerator and Android through View.performHapticFeedback.
Outside of Hotwire Native the hook falls back to navigator.vibrate where the browser has it, and does nothing where it does not.
No video on this page
Every other component page shows a screen recording. This one cannot — haptics are invisible, and simulators and emulators do not vibrate at all. Run the demo app on a real device to feel it.
You copy it, you own it
There is nothing to install. The files below are the complete component — paste the web one plus whichever platforms you ship into your app, and change them however you like. They are shown straight from the hotwire-bridge-components registry, so what you see here is what the registry holds.
Web side
Save this as bridge/useBridgeHaptic.tsx in your app:
import { useCallback } from 'react'
import { useBridgeComponent } from 'inertia-hotwire-native/react'
export type HapticFeedback = 'success' | 'warning' | 'error'
export interface BridgeHaptic {
/** Whether the connected native app plays the feedback. */
supported: boolean
/** Play one piece of feedback. Fire-and-forget. */
vibrate(feedback?: HapticFeedback): void
}
// Rough web equivalents, in milliseconds. The native side has real haptics and
// picks its own feel — this only exists so the call does something in an
// Android browser.
const WEB_PATTERNS: Record<HapticFeedback, number[]> = {
success: [12],
warning: [12, 80, 12],
error: [30, 80, 30],
}
/**
* Plays native haptic feedback inside Hotwire Native, and falls back to
* `navigator.vibrate` in browsers that have it — so a caller never has to branch
* on `supported`. That is returned for pages that want to hide a control which
* would do nothing.
*
* Nothing is ever reported back, so there is no callback to clean up.
*/
export function useBridgeHaptic(): BridgeHaptic {
const { supported, send } = useBridgeComponent('haptic')
const vibrate = useCallback(
(feedback: HapticFeedback = 'success') => {
if (supported) {
send('vibrate', { feedback })
return
}
// Absent on iOS Safari and on desktop; present but gesture-gated on
// Android. Nothing to do when it is missing.
navigator.vibrate?.(WEB_PATTERNS[feedback] ?? WEB_PATTERNS.success)
},
[supported, send]
)
return { supported, vibrate }
}Then call vibrate wherever something worth feeling happens:
import { useBridgeHaptic } from '@/bridge/useBridgeHaptic'
function SaveButton({ onSave }) {
const { vibrate } = useBridgeHaptic()
const save = async () => {
try {
await onSave()
vibrate('success')
} catch {
vibrate('error')
}
}
return <button type="button" onClick={save}>Save</button>
}| Argument | Type | Default | Purpose |
|---|---|---|---|
feedback | 'success' | 'warning' | 'error' | 'success' | Which feedback to play |
useBridgeHaptic() also returns supported, for a page that wants to hide a control that would do nothing.
The cheapest component here
Native never replies, so no callback is ever registered and there is nothing to clean up — unlike Alert and Button, where a reply arrives and the callback has to be managed. If you write your own web side for this one, send and forget.
iOS side
Add this file to your Xcode project:
import Foundation
import HotwireNative
import UIKit
/// Native counterpart of the `haptic` bridge component. Plays notification
/// feedback for the web side's `vibrate` message. Nothing is reported back.
///
/// Register once with `Hotwire.registerBridgeComponents([HapticComponent.self])`.
///
/// Follows joemasilotti/bridge-components (MIT).
final class HapticComponent: BridgeComponent {
override nonisolated class var name: String { "haptic" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
switch event {
case .vibrate:
handleVibrateEvent(message: message)
}
}
// MARK: Private
private func handleVibrateEvent(message: Message) {
guard let data: MessageData = message.data() else { return }
// An unrecognised feedback type plays success rather than nothing, per
// the contract — a page built against a newer contract still feels
// something on an older app.
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(data.feedbackType.uiKitType)
}
}
// MARK: Events
private extension HapticComponent {
enum Event: String {
case vibrate
}
}
// MARK: Message data
private extension HapticComponent {
struct MessageData: Decodable {
let feedback: String?
var feedbackType: FeedbackType {
FeedbackType(rawValue: feedback ?? "") ?? .success
}
}
enum FeedbackType: String {
case success
case warning
case error
var uiKitType: UINotificationFeedbackGenerator.FeedbackType {
switch self {
case .success: .success
case .warning: .warning
case .error: .error
}
}
}
}Then register it at launch, in AppDelegate:
Hotwire.registerBridgeComponents([
HapticComponent.self,
// … your other components
])The contract
Component name: haptic.
vibrate — web → native
Plays one piece of feedback. Fire-and-forget.
{
"feedback": "success" // "success" | "warning" | "error", optional
}There is no reply. Nothing comes back because nothing needs to — the feedback either played or the device cannot play it, and neither changes what the web side does next.
An unknown type still plays
Native must not drop a feedback it does not recognise; it plays success instead. That way a page built against a newer contract keeps working against an older app, which is the same additive rule every component here follows.
feedback is a category, not a waveform. What each one feels like is the native side's choice and differs between platforms — do not build web-side logic that assumes a duration or an intensity.
Android
The Kotlin half uses View.performHapticFeedback, so it needs no VIBRATE permission and respects the device's own haptic settings. Add this file to your project:
// Replace with your app's package.
package com.example.bridge
import android.os.Build
import android.util.Log
import android.view.HapticFeedbackConstants
import androidx.fragment.app.Fragment
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
import dev.hotwire.navigation.destinations.HotwireDestination
import kotlinx.serialization.Serializable
/**
* Native counterpart of the `haptic` bridge component. Plays haptic feedback for
* the web side's `vibrate` message. Nothing is reported back.
*
* Register once with
* `Hotwire.registerBridgeComponents(BridgeComponentFactory("haptic", ::HapticComponent))`.
*
* Uses `View.performHapticFeedback`, so no `VIBRATE` permission is needed and
* the device's own haptic settings are respected. `CONFIRM` and `REJECT` arrived
* in API 30; below that both fall back to constants that have always existed.
*/
class HapticComponent(
name: String,
private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {
private val fragment: Fragment
get() = delegate.destination.fragment
override fun onReceive(message: Message) {
when (message.event) {
"vibrate" -> handleVibrateEvent(message)
else -> Log.w("HapticComponent", "Unknown event for message: $message")
}
}
private fun handleVibrateEvent(message: Message) {
val data = message.data<MessageData>() ?: return
val view = fragment.view ?: return
// An unrecognised feedback type plays success rather than nothing, per
// the contract.
val feedback = FeedbackType.from(data.feedback)
view.performHapticFeedback(feedback.constant)
}
private enum class FeedbackType(val constant: Int) {
SUCCESS(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.CONFIRM
} else {
HapticFeedbackConstants.VIRTUAL_KEY
}
),
WARNING(HapticFeedbackConstants.LONG_PRESS),
ERROR(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.REJECT
} else {
HapticFeedbackConstants.LONG_PRESS
}
);
companion object {
fun from(value: String?) = when (value) {
"warning" -> WARNING
"error" -> ERROR
else -> SUCCESS
}
}
}
@Serializable
data class MessageData(
val feedback: String? = null
)
}Then register it at launch, in your Application:
Hotwire.registerBridgeComponents(
BridgeComponentFactory("haptic", ::HapticComponent),
// … your other components
)Three types, two constants
Android has no direct equivalent of iOS's success/warning/error triple. CONFIRM and REJECT cover success and error from API 30 onwards; below that, and for warning on every version, the component falls back to constants that have always existed. Expect the three to feel less distinct than they do on iOS.
Why you might feel nothing
In rough order of likelihood:
- A simulator or emulator. Neither vibrates, ever.
- System haptics are off, or the iPhone is in Low Power Mode, which suppresses them.
- The native half is not registered — then
supportedis false on the web side and the call goes tonavigator.vibrate, which desktop and iOS Safari do not have.
Log the message on the native side to tell the first two apart from the third.