An open, portable haptic format and registry. This document is the normative specification for the .hap.md file and the fenced haptic JSON block it contains.
Repository: github.com/Bimbi-Digital/haptics.md · Site: https://haptics.md · License: Apache-2.0 (code), CC0 (pattern snippets).
haptic/0.1-draft — unstable. This is a working draft. Field names, defaults, and the JSON grammar may change without a compatibility shim before 1.0. Do not treat 0.1 as frozen.
RFC-2119 keywords (MUST, MUST NOT, SHOULD, MAY) are used in exactly two places in this document: the Intent vocabulary and the Degradation ladder. Those two contracts are what a conforming runtime is judged against. Everything else in this draft is provisional and descriptive — read it as guidance, not obligation.
A haptic pattern is authored once, as a single .hap.md file, and rendered on whatever the target device can actually do — from a full Core Haptics waveform on an iPhone down to a crude on/off buzz on Android Chrome, down to nothing at all on a device with no actuator. A device with no actuator is a valid target that produces nothing; this is a success, not a failure.
The format is deliberately layered so that the same file carries progressively cruder representations of the same feeling:
- an optional intent — a semantic token (
impact.rigid,notification.success, …) that maps to a native platform primitive and produces the best feel; - a track — a synthesized timeline of transient and continuous events with intensity, sharpness, and curves;
- fallbacks.web — a duration-only on/off millisecond pattern for
navigator.vibrate; - silence.
A runtime walks that ladder top-down and renders the highest rung the device supports.
Honesty is a design constraint, not a footnote. Web haptics are the weakest surface anywhere: navigator.vibrate is duration-only, gesture-gated, throttled, and exists only in the Android Chrome family — it is absent on iOS Safari and on desktop. This spec never lets intensity or sharpness silently "survive" to a surface that cannot express them.
A .hap.md file has three parts, in order:
A single YAML block delimited by ---. Recognized keys:
| Key | Type | Notes |
|---|---|---|
id |
string (slug) | ^[a-z0-9]+(-[a-z0-9]+)*$. Matches the id inside the haptic block. |
title |
string | Human title. |
spec |
string | Grammar version, e.g. "0.1". Matches the block's haptic field. |
version |
string (semver) | Content version of this pattern, independent of the spec grammar version. |
category |
string | Free-form grouping, e.g. gesture, feedback, ui. |
intent |
intent token | Mirrors the block's intent, if any. |
requiredCapability |
capability token | null | Mirrors the block's requiredCapability. |
platforms |
string[] | Honest target labels, e.g. ios, android, web:best-effort. |
tags |
string[] | Search/registry tags. |
feel |
string | One-line description of the intended sensation. |
Unknown frontmatter keys are ignored, never fatal.
Free markdown between the frontmatter and the haptic block. This is for people: what the pattern is for, when to fire it, what it should feel like. It is not compiled.
A single fenced code block tagged haptic containing a JSON object. This block is the compilable source of truth. A .hap.md file MUST contain exactly one such block (this is the one structural rule outside the two RFC-2119 contracts, and it is enforced by conformance, not by RFC-2119 language here). Any prose fences (```json, ```swift) are documentation and are ignored by the compiler.
{
"haptic": "0.1",
"id": "pattern-slug",
"intent": "impact.rigid",
"track": [ /* events */ ],
"requiredCapability": "composition",
"fallbacks": { "web": [20, 40, 30], "reduced": "selection" }
}
| Field | Type | Required | Range / values | Meaning |
|---|---|---|---|---|
haptic |
string | yes | ^\d+\.\d+$ (e.g. "0.1") |
Grammar version of this block. |
id |
string | yes | ^[a-z0-9]+(-[a-z0-9]+)*$ |
Stable slug; matches frontmatter id. |
intent |
string | no | an Intent token (see below) | Semantic primitive. Resolved natively first. |
track |
array | no | ≥ 1 event | Synthesized timeline. Used when intent is absent or the device cannot render the intent. |
requiredCapability |
string | no | a Capability token | Minimum device capability the full track needs. If absent, the runtime steps down the ladder. |
fallbacks |
object | no | { web, reduced } |
Crude cross-surface fallbacks. |
track is an array of events, each:
| Field | Type | Required | Range / values | Meaning |
|---|---|---|---|---|
at |
integer | yes | ≥ 0 (milliseconds) | Start offset from the track's origin. Events should be ordered by non-decreasing at. |
type |
string | yes | "transient" | "continuous" |
Transient = a discrete tap. Continuous = a sustained vibration. |
intensity |
number | no | 0..1 |
Normalized strength. |
sharpness |
number | no | 0..1 |
Normalized "crispness" (Core Haptics term). Higher = sharper/tighter. |
duration |
integer | no | ≥ 0 (milliseconds) | Length of a continuous event. Required when type is "continuous". Ignored for transients. |
curve |
string | no | linear | easeIn | easeOut | hold |
Envelope of a continuous event's intensity over its duration. |
primitive |
string | no | platform hint string | Optional native-primitive hint (e.g. an Android composition primitive name) a compiler MAY honor. |
| Field | Type | Range / values | Meaning |
|---|---|---|---|
web |
integer[] | each ≥ 0 (ms) | On/off pattern for navigator.vibrate([...]). Odd indices are pauses. Duration-only — carries no intensity or sharpness. |
reduced |
string | an Intent token | A gentler semantic substitute for the reduced-feedback path. |
Unknown fields anywhere in the object are ignored, never fatal. Future grammar versions add fields; a 0.1 runtime encountering a 0.2 field skips it and renders what it understands.
The Tier-0 intent tokens are frozen for 0.1. A conforming runtime MUST resolve each of these tokens to the platform's native semantic primitive when one exists, and MUST NOT invent a token outside this set under the 0.1 grammar. Tokens outside this set MAY appear in future tiers and MUST be treated as unknown (ignored, stepped down the ladder) by a 0.1 runtime.
| Token | Intended feeling |
|---|---|
impact.light |
A light tap against a surface. |
impact.medium |
A medium tap. |
impact.heavy |
A heavy, weighty tap. |
impact.soft |
A dull, cushioned tap. |
impact.rigid |
A stiff, crisp tap. |
notification.success |
The system "it worked" signature. |
notification.warning |
The system "careful" signature. |
notification.error |
The system "it failed" signature. |
selection |
A tick as a discrete value changes (picker, segmented control). |
A runtime MUST NOT map a token to a native primitive that contradicts its meaning. Where no native primitive exists (see the mapping table), the runtime MUST step down the ladder rather than fake it.
requiredCapability and the runtime's device probe use these tokens:
| Token | Device can… | Probed via |
|---|---|---|
amplitude |
vary per-event intensity | iOS: Core Haptics always. Android: Vibrator.hasAmplitudeControl() == true. |
sharpness |
vary sharpness independently | iOS Core Haptics only. No Android/web/RN/Flutter equivalent. |
curve |
render a parameter envelope over a continuous event | iOS Core Haptics CHHapticParameterCurve. |
composition |
play Android primitive compositions | Android: VibrationEffect.Composition.areAllPrimitivesSupported(...) == true (API 30+). Many mid-range ERM devices report false. |
If a pattern declares requiredCapability and the device lacks it, the runtime skips the track rung and continues down the ladder.
This table is the heart of the spec. Each row is a format construct; each column is a target. A cell is either the exact native API parameter used, or unsupported -> falls back to X where X is the next rung. All API names are real and current as of this draft.
Column meanings, honestly labeled:
- iOS — first-class.
UIFeedbackGenerator/ iOS 17.sensoryFeedbackfor intents; Core Haptics + AHAP for tracks. iPhone 8+. - Android — first-class.
VibrationEffect+VibrationEffect.Composition.VibratorManageris API 31+;Vibratorbelow that. Amplitude control is API 26+ and many devices reporthasAmplitudeControl() == false. Requires theVIBRATEmanifest permission. - web — web (Android Chrome), best-effort, duration-only.
navigator.vibrateonly. No intensity, no sharpness. Gesture-gated, throttled, Android-Chrome-family only. Does not exist on iOS Safari or on desktop — there it is a silent no-op. - React Native — intent-tier only.
expo-hapticsexposes semantic constants and nothing else. There is no public custom-waveform API; a native module for tracks is not yet shipped. - Flutter — intent-tier only.
HapticFeedbackexposes a small set of semantic constants and nothing else.
| Construct | iOS | Android | web | React Native | Flutter |
|---|---|---|---|---|---|
intensity 0..1 |
CHHapticEventParameter(.hapticIntensity) |
amplitude 1..255 via createWaveform/createOneShot when hasAmplitudeControl()==true; else DEFAULT_AMPLITUDE (on/off) → intensity dropped, duration-only |
unsupported -> dropped (vibrate is duration-only) |
unsupported -> falls back to intent (no amplitude param) |
unsupported -> falls back to intent |
sharpness 0..1 |
CHHapticEventParameter(.hapticSharpness) |
no API → approximated by primitive choice on composition path (e.g. PRIMITIVE_TICK sharp vs PRIMITIVE_THUD dull); unsupported -> dropped on waveform path |
unsupported -> dropped |
unsupported -> falls back to intent |
unsupported -> falls back to intent |
| transient event | CHHapticEvent(.hapticTransient) |
createOneShot(ms, amp) or Composition primitive (PRIMITIVE_CLICK/PRIMITIVE_TICK) |
one pulse from fallbacks.web → navigator.vibrate(ms) |
unsupported -> falls back to intent (nearest impact constant) |
unsupported -> falls back to intent |
continuous event (+duration) |
CHHapticEvent(.hapticContinuous, duration) + intensity/sharpness params |
sustained createWaveform/createOneShot(duration); amplitude only if hasAmplitudeControl() |
single sustained navigator.vibrate(duration), no amplitude |
unsupported -> falls back to intent |
unsupported -> falls back to intent |
| curve | CHHapticParameterCurve (control points over time) |
approximated by multi-step createWaveform amplitudes; no true curve; unsupported -> dropped on composition path |
unsupported -> dropped |
unsupported -> falls back to intent |
unsupported -> falls back to intent |
| impact.light | UIImpactFeedbackGenerator(.light) / .sensoryFeedback(.impact(weight:.light)) |
Composition PRIMITIVE_LOW_TICK; else createPredefined(EFFECT_TICK); else duration-only |
fallbacks.web → navigator.vibrate([...]) |
Haptics.impactAsync(ImpactFeedbackStyle.Light) |
HapticFeedback.lightImpact() |
| impact.medium | UIImpactFeedbackGenerator(.medium) |
Composition PRIMITIVE_CLICK; else createPredefined(EFFECT_CLICK); else duration-only |
fallbacks.web → vibrate |
Haptics.impactAsync(Medium) |
HapticFeedback.mediumImpact() |
| impact.heavy | UIImpactFeedbackGenerator(.heavy) |
Composition PRIMITIVE_THUD (API 31+) or PRIMITIVE_CLICK@1.0; else createPredefined(EFFECT_HEAVY_CLICK); else duration-only |
fallbacks.web → vibrate |
Haptics.impactAsync(Heavy) |
HapticFeedback.heavyImpact() |
| impact.soft | UIImpactFeedbackGenerator(.soft) (iOS 13+) |
Composition PRIMITIVE_LOW_TICK; else createPredefined(EFFECT_TICK); else duration-only |
fallbacks.web → vibrate |
Haptics.impactAsync(Soft) |
no native equivalent → unsupported -> falls back to impact.light (HapticFeedback.lightImpact()) |
| impact.rigid | UIImpactFeedbackGenerator(.rigid) (iOS 13+) |
Composition PRIMITIVE_CLICK (sharp) / PRIMITIVE_TICK; else createPredefined(EFFECT_CLICK); else duration-only |
fallbacks.web → vibrate |
Haptics.impactAsync(Rigid) |
no native equivalent → unsupported -> falls back to impact.heavy (HapticFeedback.heavyImpact()) |
| notification.success | UINotificationFeedbackGenerator().notificationOccurred(.success) |
no native semantic → synthesized from track/Composition (e.g. ascending PRIMITIVE_CLICK pair); else duration-only |
fallbacks.web → vibrate |
Haptics.notificationAsync(NotificationFeedbackType.Success) |
no native notification API → unsupported -> falls back to track, else impact.medium |
| notification.warning | .notificationOccurred(.warning) |
synthesized from track/Composition; else duration-only |
fallbacks.web → vibrate |
Haptics.notificationAsync(Warning) |
unsupported -> falls back to track, else impact.medium |
| notification.error | .notificationOccurred(.error) |
synthesized from track/Composition; else duration-only |
fallbacks.web → vibrate |
Haptics.notificationAsync(Error) |
unsupported -> falls back to track, else impact.heavy |
| selection | UISelectionFeedbackGenerator().selectionChanged() |
Composition PRIMITIVE_TICK / createPredefined(EFFECT_TICK); else HapticFeedbackConstants.CLOCK_TICK; else duration-only |
fallbacks.web → vibrate |
Haptics.selectionAsync() |
HapticFeedback.selectionClick() |
Notes:
- Android amplitude is the recurring cliff. A pattern that assumes graded intensity gets a flat on/off buzz on any device where
hasAmplitudeControl()isfalse. Emitted Android code must guard withhasAmplitudeControl()/areAllPrimitivesSupported(...)and carry a real fallback — never a silent lie that amplitude worked. - web never carries amplitude or sharpness. Those parameters are dropped at the boundary. This is by design and stated plainly wherever web is a target.
- RN and Flutter are intent-only surfaces. A
trackon those platforms resolves through the pattern'sintent(if present) to the nearest constant, or falls silent. Custom waveforms require a native module that is not part of this draft.
This is a normative contract. A conforming runtime MUST implement it top-down and render the highest rung the device supports.
- intent — if the object has an
intentand the platform has a native semantic primitive for it (per the mapping table), the runtime MUST render that primitive. This is the best feel. - track — else, if the object has a
trackand the device satisfiesrequiredCapability(or none is declared), the runtime SHOULD render the synthesized timeline using the platform's per-event APIs. - fallbacks.web — on the web target, or where no richer rung is available, the runtime MUST use
fallbacks.webwithnavigator.vibratewhere it exists, and MUST treat its absence (iOS Safari, desktop) as a silent no-op rather than an error. - silent no-op — if nothing above can render, the runtime MUST do nothing and report success.
Hard invariants:
- A device with no actuator is a valid target that produces nothing.
- Application logic MUST NEVER gate on haptic success. Firing a pattern is fire-and-forget; the surrounding UI action MUST proceed identically whether or not anything buzzed.
- A runtime MUST NOT lie that a parameter took effect. If amplitude/sharpness/curve was dropped, any capability report the runtime exposes MUST reflect that it was dropped.
- The
reducedfallback and the OS system-haptics setting (see Accessibility) MUST be honored before the fullintent/trackrungs.
Haptics are a feedback and accessibility channel — including for deaf and hard-of-hearing users — and are governed by consent, not by motion sensitivity. A runtime MUST respect the OS system-haptics setting (on iOS: Settings → Accessibility → Touch → Vibration), not prefers-reduced-motion. When the OS setting is off, the runtime treats every pattern as a silent no-op. Frame haptics as feedback-with-consent.
A conforming pattern and a conforming runtime are judged on two axes.
A .hap.md file is structurally conformant when:
- it contains exactly one fenced
hapticblock; - that block validates against the published JSON Schema for its
hapticversion; idmatches the frontmatteridand the slug pattern;- every
intentandfallbacks.reducedvalue is a Tier-0 token; trackevents are ordered by non-decreasingat, and everycontinuousevent carries aduration;- unknown fields are present-but-ignored, never causing a hard failure.
A conforming runtime implements the degradation ladder and its hard invariants above, and never gates app logic on haptic success.
A pattern is only "verified" when a person has felt it on real hardware. Structural validity is necessary but not sufficient. Verification requires:
- felt on a physical Android device (with and without amplitude control, ideally) and a physical iPhone;
- demo media led on Android for the web/
navigator.vibratepath, because that is the only place web haptics fire; - an explicit "haptics unavailable on iOS Safari" state in any web playground — never a fake or hacky buzz to paper over the gap.
Emulators and desktop browsers cannot verify feel.
Two independent version lines:
- Grammar version — the block's
hapticfield and frontmatterspec("0.1"). Governs the JSON shape and this document. Pre-1.0it is unstable; breaking changes MAY land without migration. - Content version — the frontmatter
version(semver) of an individual pattern. A pattern author bumps this when the feeling changes, independent of the grammar.
A runtime built for 0.1 MUST ignore unknown fields from later grammar versions rather than reject the block, so that newer patterns degrade gracefully on older runtimes.
| Platform | Tier | In scope now | Out of scope / honest limit |
|---|---|---|---|
iOS (UIFeedbackGenerator, iOS 17 .sensoryFeedback, Core Haptics + AHAP) |
First-class | Full intent + track + curve + sharpness. iPhone 8+. | Pre-iOS-13 soft/rigid; devices without the Taptic Engine (produce nothing — valid). |
| Android (VibrationEffect + Composition) | First-class | Intent + track; amplitude when hasAmplitudeControl(); primitives when areAllPrimitivesSupported(). |
Sharpness has no true API. Mid-range ERM devices: no amplitude, no composition → duration-only. Requires VIBRATE permission. |
web (navigator.vibrate) |
First-class, best-effort, duration-only | On/off fallbacks.web on the Android Chrome family, gesture-gated. |
No intensity, no sharpness, no continuous amplitude. Absent on iOS Safari and desktop → silent no-op. |
React Native (expo-haptics) |
Intent-tier only | The nine Tier-0 tokens via semantic constants. | Custom track / sharpness / curve — needs a native module not yet shipped. |
Flutter (HapticFeedback) |
Intent-tier only | A subset of the tokens via semantic constants. | impact.soft/impact.rigid/notification.* have no native constant → documented fallbacks. Custom waveforms out of scope. |
| Game controllers (Gamepad rumble) | Rumble-only escape hatch | Map intensity onto weak/strong motors. | No waveform, no sharpness, no transients. A blunt approximation, not a target for authored tracks. |
The code is Apache-2.0; pattern snippets are CC0. Apache-2.0's patent grant covers only patents held by contributors on their own contributions. It does not provide freedom-to-operate against third-party haptic patents (for example, patents held by Immersion). Patterns in the registry are built only from public, documented platform APIs, but that is not a clearance guarantee — no such guarantee is offered or implied. Contributions require a DCO sign-off.