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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add hapticspnpm dlx ermineui add hapticsyarn dlx ermineui add hapticsbunx ermineui add hapticsThat 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
| File | Where it goes |
|---|---|
directive.ts | src/components/ui/haptics/directive.ts |
haptic.ts | src/components/ui/haptics/haptic.ts |
index.ts | src/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'