Haptics

Short vibration patterns for presses and results on phones that support it, added with v-haptic.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add haptics
pnpm dlx ermineui add haptics
yarn dlx ermineui add haptics
bunx ermineui add haptics

That copies 3 files into src/components/ui/haptics/. Files you already have are kept.

Use

import { vHaptic, canVibrate, haptic, hapticNames, hapticSettings, useHaptics } from '@/components/ui/haptics'

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
directive.tssrc/components/ui/haptics/directive.ts
haptic.tssrc/components/ui/haptics/haptic.ts
index.tssrc/components/ui/haptics/index.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

directive.ts 53 lines
import type { Directive } from 'vue'
import { haptic, type HapticName } from './haptic'

/**
 * v-haptic — a small buzz when a control is used, on phones that can. Pairs well with v-sound.
 *
 *   <Button v-haptic>Save</Button>                 a normal press
 *   <Button v-haptic="'heavy'">Delete</Button>     a named pattern
 *   <Switch v-haptic.toggle />                     firmer when switching on than off
 *   <Button v-haptic="false">Quiet</Button>        switched off
 */
type HapticBinding = HapticName | boolean | undefined

interface State {
  value: HapticBinding
  toggle: boolean
  onClick: () => void
}

const states = new WeakMap<HTMLElement, State>()

function isOn(el: HTMLElement): boolean {
  if (el instanceof HTMLInputElement) return el.checked
  return el.getAttribute('aria-pressed') === 'true' || el.getAttribute('aria-checked') === 'true'
}

export const vHaptic: Directive<HTMLElement, HapticBinding> = {
  mounted(el, binding) {
    const state: State = {
      value: binding.value,
      toggle: !!binding.modifiers.toggle,
      onClick: () => {
        if (state.value === false) return
        // Checked after the control has updated itself, so the buzz matches where it ended up.
        if (state.toggle) queueMicrotask(() => haptic(isOn(el) ? 'medium' : 'light'))
        else haptic(typeof state.value === 'string' ? state.value : 'medium')
      },
    }
    states.set(el, state)
    el.addEventListener('click', state.onClick)
  },
  updated(el, binding) {
    const state = states.get(el)
    if (state) state.value = binding.value
  },
  unmounted(el) {
    const state = states.get(el)
    if (!state) return
    el.removeEventListener('click', state.onClick)
    states.delete(el)
  },
}
haptic.ts 87 lines
import { effectScope, reactive, watch } from 'vue'

/**
 * Vibration feedback through the Vibration API. Android phones support it; iPhones and desktop
 * browsers do not, and there it quietly does nothing — so treat it as a garnish, never as the message.
 */

/** Each pattern is milliseconds of buzz, pause, buzz, pause ... Short and light: a tap, not an alarm. */
const PATTERNS = {
  /** The lightest touch: a key press, a selection moving. */
  light: [8],
  /** A normal button press. The default. */
  medium: [14],
  /** Something weighty: confirming, deleting. */
  heavy: [26],
  /** Two quick taps: it worked. */
  success: [12, 60, 18],
  /** Three firm taps: it did not. */
  error: [22, 50, 22, 50, 22],
  /** A tiny tick, for stepping through values (a slider, a picker). */
  selection: [4],
} satisfies Record<string, number[]>

export type HapticName = keyof typeof PATTERNS
export const hapticNames = Object.keys(PATTERNS) as HapticName[]

const STORAGE_KEY = 'er-haptics'

/** Whether vibration is used. Shared by the whole page and remembered between visits. */
export const hapticSettings = reactive({ enabled: true })

let ready = false
function prepare() {
  if (ready || typeof window === 'undefined') return
  ready = true
  try {
    const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null') as { enabled?: unknown } | null
    if (typeof saved?.enabled === 'boolean') hapticSettings.enabled = saved.enabled
    // Someone who has asked their system for less motion gets no buzzing until they ask for it.
    else if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) hapticSettings.enabled = false
  } catch {
    // Storage can be blocked. The default still works.
  }
  effectScope(true).run(() =>
    watch(
      () => hapticSettings.enabled,
      (enabled) => {
        try {
          window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ enabled }))
        } catch {
          // Not being able to save the choice is not worth interrupting anyone for.
        }
      },
    ),
  )
}

/**
 * For use inside components: `const { haptic, settings, supported } = useHaptics()`. Calling it
 * loads the saved choice straight away, which is what a settings switch bound to `settings.enabled` needs.
 */
export function useHaptics() {
  prepare()
  return { haptic, settings: hapticSettings, supported: canVibrate() }
}

/** True where the device can vibrate at all. Use it to hide a "vibration" setting that would do nothing. */
export function canVibrate(): boolean {
  return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'
}

/**
 * Vibrates now, unless haptics are switched off or the device cannot. Returns whether it buzzed.
 * Browsers only allow this in response to a tap or key press.
 */
export function haptic(name: HapticName | number[] = 'medium'): boolean {
  prepare()
  if (!hapticSettings.enabled || !canVibrate()) return false
  const pattern = typeof name === 'string' ? PATTERNS[name] : name
  if (!pattern) return false
  try {
    return navigator.vibrate(pattern)
  } catch {
    return false
  }
}
index.ts 4 lines
/** Haptics — short vibration patterns for presses and results on phones that support it, added with v-haptic. */
export { vHaptic } from './directive'
export { canVibrate, haptic, hapticNames, hapticSettings, useHaptics, type HapticName } from './haptic'