# useHaptics

> Semantic haptic feedback that routes each intent to the right native API per platform, using performAndroidHapticsAsync on Android instead of the Vibrator-simulated impact APIs.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/hooks/useHaptics
- Markdown: https://ui.ahmedbna.com/docs/hooks/useHaptics.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useHaptics.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useHaptics.json
- Install: `npx bna-ui add useHaptics`
- npm dependencies: `expo-haptics`

---

## Installation

### CLI

```bash
npx bna-ui add useHaptics
```

### Manual

**1.** Copy and paste the following code into your project.

````ts
// hooks/useHaptics.ts
import * as Haptics from 'expo-haptics';
import { useCallback } from 'react';
import { Platform } from 'react-native';

/**
 * What a haptic is *for*, rather than which API to call.
 *
 * iOS and web share one API surface — `impactAsync` / `notificationAsync` /
 * `selectionAsync`. Web is not a no-op: expo-haptics drives `navigator.vibrate`
 * there, falling back to an iOS-Safari switch-element trick.
 *
 * Android must not use those. They are simulated with the raw `Vibrator`
 * service, which Expo explicitly does not recommend: it is a coarse timed buzz
 * and nothing like what native Android controls feel like.
 * `performAndroidHapticsAsync` goes through `View.performHapticFeedback`
 * instead, which is exactly what those controls use.
 *
 * Branching on that is the whole reason this file exists, so that no component
 * has to know about it.
 */
export type HapticIntent =
  | 'selection'
  | 'tick'
  | 'toggle-on'
  | 'toggle-off'
  | 'impact-light'
  | 'impact-medium'
  | 'success'
  | 'warning'
  | 'error';

const ANDROID_HAPTICS: Record<HapticIntent, Haptics.AndroidHaptics> = {
  selection: Haptics.AndroidHaptics.Segment_Tick,
  // Clock_Tick rather than Segment_Frequent_Tick: the latter is documented as
  // possibly producing no vibration at all on devices that cannot make a
  // suitably soft one, which would silently drop repeated ticks.
  tick: Haptics.AndroidHaptics.Clock_Tick,
  'toggle-on': Haptics.AndroidHaptics.Toggle_On,
  'toggle-off': Haptics.AndroidHaptics.Toggle_Off,
  'impact-light': Haptics.AndroidHaptics.Virtual_Key,
  'impact-medium': Haptics.AndroidHaptics.Long_Press,
  success: Haptics.AndroidHaptics.Confirm,
  warning: Haptics.AndroidHaptics.Reject,
  error: Haptics.AndroidHaptics.Reject,
};

function perform(intent: HapticIntent): Promise<void> {
  if (Platform.OS === 'android') {
    return Haptics.performAndroidHapticsAsync(ANDROID_HAPTICS[intent]);
  }

  switch (intent) {
    case 'selection':
    case 'tick':
      return Haptics.selectionAsync();
    case 'impact-medium':
      return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
    case 'success':
      return Haptics.notificationAsync(
        Haptics.NotificationFeedbackType.Success
      );
    case 'warning':
      return Haptics.notificationAsync(
        Haptics.NotificationFeedbackType.Warning
      );
    case 'error':
      return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
    default:
      // toggle-on, toggle-off, impact-light
      return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
  }
}

/**
 * Fire and forget. Never throws and never rejects.
 *
 * Haptics are decoration: a device with no taptic engine, a user who turned
 * touch feedback off, or a build where the native module is missing (which
 * makes expo-haptics reject with `UnavailabilityError`) must not be able to
 * take an `onPress` handler down with it.
 *
 * Call this on the JS thread. The expo module is bound to the JS runtime, so
 * invoking it from a Reanimated worklet running on the UI thread will throw —
 * reach for `runOnJS` at those call sites.
 */
export function triggerHaptic(intent: HapticIntent = 'impact-light'): void {
  try {
    perform(intent).catch(() => {});
  } catch {
    // Unreachable today, since every expo-haptics entry point is async. Kept so
    // that a future version throwing synchronously cannot break a press either.
  }
}

/**
 * Returns a stable trigger that no-ops while `enabled` is false — the shape
 * every component's `haptic` prop plugs into:
 *
 * ```tsx
 * const feedback = useHaptics(haptic);
 * feedback('toggle-on');
 * ```
 *
 * Its identity only changes when `enabled` does, so it is safe to list in a
 * `useCallback` dependency array and will not defeat a `React.memo` boundary.
 */
export function useHaptics(enabled: boolean = true) {
  return useCallback(
    (intent: HapticIntent = 'impact-light') => {
      if (!enabled) return;
      triggerHaptic(intent);
    },
    [enabled]
  );
}
````

**2.** Update the import paths to match your project setup.

## Usage

```tsx
import { useHaptics, triggerHaptic } from '@/hooks/useHaptics';
```

```tsx
export function SaveButton({ haptic = true, onSave }) {
  const feedback = useHaptics(haptic);

  return (
    <Button
      haptic={false}
      onPress={() => {
        feedback('success');
        onSave();
      }}
    >
      Save
    </Button>
  );
}
```

You describe what the haptic is _for_ — a selection, a toggle, a success — and
the hook picks the API. Every component in this library that has a `haptic`
prop is built on it, so you rarely call it directly unless you are building
your own control.

```tsx
import { useHaptics } from '@/hooks/useHaptics';

export function Stepper({ value, onChange, haptic = true }) {
  const feedback = useHaptics(haptic);

  return (
    <Button
      haptic={false}
      onPress={() => {
        feedback('tick');
        onChange(value + 1);
      }}
    >
      +
    </Button>
  );
}
```

## API Reference

### useHaptics

```tsx
const feedback = useHaptics(enabled?: boolean);
```

Returns a stable trigger `(intent?: HapticIntent) => void` that does nothing
while `enabled` is `false`. Its identity only changes when `enabled` does, so
it is safe in a `useCallback` dependency array and will not defeat a
`React.memo` boundary.

| Parameter | Type      | Default | Description                                                    |
| --------- | --------- | ------- | -------------------------------------------------------------- |
| `enabled` | `boolean` | `true`  | Whether the returned trigger fires. Pass a `haptic` prop here. |

### triggerHaptic

```tsx
triggerHaptic(intent?: HapticIntent): void;
```

The module-level equivalent, for call sites that are not component bodies — a
render prop, a handler defined outside a component, a `runOnJS` target.

Both are fire-and-forget: they never throw and never reject. A device with no
taptic engine, or a build where the native module is missing, must not be able
to take a press handler down with it.

## Platform Differences

iOS and web share one API surface. Web is not a no-op — Expo drives the
[Web Vibration API](https://caniuse.com/vibration) there, falling back to an
iOS-Safari switch-element trick.

Android is the reason this hook exists. `impactAsync` and `notificationAsync`
are simulated on Android with the raw
[`Vibrator`](https://developer.android.com/reference/android/os/Vibrator)
service, which Expo explicitly does not recommend: it is a coarse timed buzz,
nothing like a native Android control.
[`performAndroidHapticsAsync`](https://docs.expo.dev/versions/latest/sdk/haptics/#hapticsperformandroidhapticsasynctype)
goes through `View.performHapticFeedback` instead — the same engine those
controls use.

| Intent          | iOS & Web                    | Android        |
| --------------- | ---------------------------- | -------------- |
| `selection`     | `selectionAsync()`           | `Segment_Tick` |
| `tick`          | `selectionAsync()`           | `Clock_Tick`   |
| `toggle-on`     | `impactAsync(Light)`         | `Toggle_On`    |
| `toggle-off`    | `impactAsync(Light)`         | `Toggle_Off`   |
| `impact-light`  | `impactAsync(Light)`         | `Virtual_Key`  |
| `impact-medium` | `impactAsync(Medium)`        | `Long_Press`   |
| `success`       | `notificationAsync(Success)` | `Confirm`      |
| `warning`       | `notificationAsync(Warning)` | `Reject`       |
| `error`         | `notificationAsync(Error)`   | `Reject`       |

`tick` maps to `Clock_Tick` rather than `Segment_Frequent_Tick` deliberately:
the latter is documented as possibly producing no vibration at all on devices
that cannot make a suitably soft one, which would silently drop repeated ticks
on cheaper actuators.

## When feedback is silent

Haptics can be unavailable for reasons that are not bugs:

- **Android** — the user turned off Settings → Sound & vibration →
  Vibration & haptics → Touch feedback. `performHapticFeedback` is then
  suppressed system-wide.
- **iOS** — Low Power Mode is on, the user disabled the Taptic Engine, or the
  camera or dictation is active (iOS silences haptics to protect both).
- **Web** — the browser does not support the Vibration API, the device has no
  vibration hardware, or the tab is backgrounded.
- **Any platform** — the component was given `haptic={false}`.
