haptic_kit

pub package License: MIT

Haptic feedback, vibration and animated UI widgets for Flutter — full Android & iOS implementations covering everything from quick UI taps to custom Core Haptics patterns with intensity and sharpness curves, plus a set of production-ready widgets wired to the right haptic at the right moment.

PressAndHoldToConfirm progress ring filling while held HapticBounce squashing and bouncing back on tap Buttons playing semantic haptics and compositions

Haptics don't show up on video, so the clips show a "haptic fired: ..." pill each time one plays. They are recorded from the example app's auto-playing showcase on the iOS simulator.

Previously developed under the names flutter_vibration_animation and flutter_haptics. The repository URL is unchanged — only the package name and class identifiers were updated for consistency with the actual surface and pub.dev naming rules.


Documentation

Full hosted documentation lives on the Codigee open-source site:

  • Overview — what the package is, installation and quick start.
  • Animated widgets — the drop-in widget catalog (HapticBounce, PressAndHoldToConfirm, …).
  • Patterns & haptics API — Haptics, Vibration, HapticPattern and VibrationPatterns.

Features

  • Haptics — short, semantic taps (named Haptics to avoid clashing with Flutter's own HapticFeedback)
    • Impact: light, medium, heavy, soft, rigid
    • Notification: success, warning, error
    • Selection (for pickers, sliders, segmented controls)
    • prepare() to pre-warm generators on iOS for lowest latency (returns true on iOS, false no-op on Android)
  • Haptics.semantic - name the UI event instead of a strength (confirm, reject, toggleOn, toggleOff, dragStart, gestureThresholdActivate, segmentTick, ...) and get each platform's native signal for it. On Android this is View.performHapticFeedback, which respects the user's touch-feedback setting
  • HapticComposition - Android 11+ composition primitives (click, tick, lowTick, quickRise, slowRise, quickFall, thud, spin) with per-primitive scale and delay, approximated with Core Haptics on iOS and a waveform on older Android
  • Vibration — longer-form vibrations
    • One-shot vibration with optional amplitude
    • Custom waveforms with per-segment amplitudes
    • Predefined OS effects (tick, click, doubleClick, heavyClick)
    • Cancel any running vibration
  • HapticPattern — fluent builder for Core Haptics patterns
    • Transient taps + continuous events
    • Per-event intensity and sharpness (0.0–1.0)
    • Automatic translation to Android amplitude waveforms
  • VibrationPatterns — ready-made: heartbeat, notification, alarm, tick, success, failure, charge-up
  • HapticCapabilities — runtime detection of vibrator hardware, amplitude control, Core Haptics, predefined effects
  • HapticBounce — drop-in tap wrapper with squash + recoil + elastic settle bounce (3-segment TweenSequence), wired to light/medium impact
  • PressAndHoldToConfirm — long-press confirmation with a finger-tracking progress ring and a 12-tick densifying haptic schedule that escalates from selection → light → medium → heavy

Platform support

Feature Android iOS
Impact / notification / selection ✅ API 21+ (best on 26+) ✅ iOS 10+
One-shot + amplitude ✅ API 26+ ✅ iPhone 8+ (Core Haptics)
Custom waveforms ✅ API 26+ ✅ iPhone 8+
Predefined effects ✅ API 29+ ↩︎ mapped to closest impact
Custom patterns (intensity + sharpness) ✅ API 26+ ✅ iPhone 8+
Semantic UI signals (Haptics.semantic) ✅ API 21+, newest constants on 30+ / 34+ ✅ mapped to UIKit generators
Composition primitives ✅ native on API 30+ (some 31+), approximated below approximated with Core Haptics
Capability detection ✅ ✅

Installation

dependencies:
  haptic_kit: ^2.3.0

Android

The plugin's AndroidManifest.xml already declares VIBRATE — nothing else to do.

iOS

CoreHaptics, UIKit and AudioToolbox are linked automatically through the podspec. Minimum deployment target: iOS 12.0.

Quick start

import 'package:haptic_kit/haptic_kit.dart';

// Short UI taps
await Haptics.impact(HapticImpactStyle.medium);
await Haptics.notification(HapticNotificationStyle.success);
await Haptics.selection();

// Longer vibrations
await Vibration.vibrate(duration: const Duration(milliseconds: 300));

// Custom waveform — three pulses with growing amplitude
await Vibration.vibrateWaveform(
  timings: const [
    Duration.zero,
    Duration(milliseconds: 100),
    Duration(milliseconds: 100),
    Duration(milliseconds: 100),
    Duration(milliseconds: 100),
    Duration(milliseconds: 100),
  ],
  amplitudes: const [0, 80, 0, 160, 0, 255],
);

// Predefined OS effect
await Vibration.playPredefined(PredefinedEffect.doubleClick);

// Ready-made pattern
await VibrationPatterns.heartbeat();

Custom haptic patterns (Core Haptics)

await HapticPattern.builder()
    .tap(intensity: 0.4, sharpness: 0.6)
    .pause(const Duration(milliseconds: 80))
    .tap(intensity: 1.0, sharpness: 0.9)
    .continuous(
      duration: const Duration(milliseconds: 250),
      intensity: 0.7,
      sharpness: 0.3,
    )
    .play();
  • On iOS (iPhone 8+) this renders as a CHHapticPattern with hapticTransient / hapticContinuous events.
  • On Android (API 26+) intensity is mapped to amplitude; sharpness is ignored (no perceptual analogue).
  • On older devices, play() throws UnsupportedHapticException — guard with HapticCapabilities.query() if you need graceful degradation.

Semantic haptics

Name what happened in the UI and let each platform pick its own signal:

// A plain Switch (HapticToggle already ticks on its own)
Switch(
  value: _enabled,
  onChanged: (on) {
    Haptics.semantic(on ? SemanticHaptic.toggleOn : SemanticHaptic.toggleOff);
    setState(() => _enabled = on);
  },
)

// Swipe-to-delete crossing its threshold
Haptics.semantic(SemanticHaptic.gestureThresholdActivate);
  • On Android the call goes through View.performHapticFeedback, the path Google recommends for UI feedback: it follows the user's system touch-feedback setting (nothing plays when they turned it off) and the device maker's tuning. Newer HapticFeedbackConstants are used where the OS has them (CONFIRM, REJECT, GESTURE_START / GESTURE_END on API 30+, TOGGLE_ON / TOGGLE_OFF, DRAG_START, SEGMENT_TICK, GESTURE_THRESHOLD_* on API 34+), with the closest older constant below that. Without an attached activity it falls back to a predefined vibration.
  • On iOS each value maps to the closest UIFeedbackGenerator; the mapping is listed on every SemanticHaptic value.

Composition primitives

Buttons playing semantic haptics and compositions
await HapticComposition.builder()
    .add(HapticPrimitive.slowRise, scale: 0.6)
    .add(HapticPrimitive.click, delay: const Duration(milliseconds: 40))
    .play();

final native = await HapticComposition.supportedPrimitives();
if (native.contains(HapticPrimitive.thud)) {
  // Rendered natively by the vibrator.
}
  • On Android 11+ (API 30, with lowTick, thud and spin on API 31) the composition is a VibrationEffect.Composition, played only when Vibrator.areAllPrimitivesSupported says the vibrator has every primitive in it.
  • Everywhere else (iOS, older Android, or a vibrator missing a primitive) the whole composition is approximated with intensity + sharpness events, through Core Haptics on iOS and a waveform on Android. It is never played half native, half approximated.
  • play() works on every device; HapticComposition.supportedPrimitives() tells you which primitives would feel native.

Animated widgets

The library ships with a set of drop-in widgets that combine an animation with the right haptic at the right moment. Each one is a single self-contained file in lib/src/widgets/ — read one, copy the pattern.

HapticBounce squashing and bouncing back on tap
HapticBounce
PressAndHoldToConfirm progress ring filling while held
PressAndHoldToConfirm
HapticToggle flipping on and off
HapticToggle
HapticSlider ticking at each detent
HapticSlider
HapticStepper counting up to its limit
HapticStepper
HapticShake wiggling a wrong PIN
HapticShake
HapticPulse breathing on a notification bell
HapticPulse
SlideToConfirm dragged to the end
SlideToConfirm
HapticRating stars filling one by one
HapticRating
Widget What it does Pattern
HapticBounce Tap → squash → recoil → elastic settle 3-segment TweenSequence, controller-driven
PressAndHoldToConfirm Hold to confirm with ring + densifying ticks One controller drives ring + haptics + callback
HapticToggle Animated switch + tick on flip Custom-painted thumb with easeOutBack slide
HapticSlider Slider with detent ticks Detect detent crossings via lastIndex cache
HapticStepper −/+ counter with bouncing buttons Composes HapticBounce + AnimatedSwitcher
HapticShake Wiggle + error notification Externally triggered via GlobalKey<State>.shake()
HapticPulse Looping breathing pulse + tick per beat Auto-playing controller, start()/stop() via GlobalKey<State>
SlideToConfirm Drag handle to end to confirm Drag-driven controller with snap-back
HapticRating Tap a star → cascading fill + tick per star Sequenced Timer.periodic

HapticBounce — tactile bounce on tap

Wraps any widget with a press-down → recoil → elastic-settle animation synchronised with a light/medium impact. Drop-in replacement for GestureDetector(onTap: …) on buttons that should feel alive.

HapticBounce(
  onTap: () => doSomething(),
  child: Container(
    padding: const EdgeInsets.all(24),
    decoration: const BoxDecoration(/* ... */),
    child: const Text('Press me'),
  ),
)

The scale follows a 3-segment TweenSequence with weights 1 : 2 : 3:

  1. squash — 1.0 → 0.92, easeIn
  2. recoil — 0.92 → 1.12 (overshoots 1.0), easeOutCubic
  3. settle — 1.12 → 1.0, elasticOut

Set bounceOnRelease: false for a plain symmetric press with no overshoot.

pressedScale must be in (0, 1) and overshootScale must be >= 1.0 — violations throw ArgumentError at construction time, both in debug and release.

PressAndHoldToConfirm — long-press with progress ring

Requires the user to hold for holdDuration before firing onConfirm. A circular progress ring renders at the finger position, and a 12-tick haptic schedule fires at progressively shorter intervals — escalating from selection → light → medium → heavy, sealed with a final heavy impact at completion.

final key = GlobalKey<PressAndHoldToConfirmState>();

PressAndHoldToConfirm(
  key: key,
  holdDuration: const Duration(seconds: 2),
  onConfirm: () => unbox(),
  child: const SizedBox(
    height: 240,
    child: Center(child: Icon(Icons.card_giftcard, size: 96)),
  ),
)

// Re-arm for another confirmation later:
key.currentState?.reset();

Architecture notes:

  • A single AnimationController drives the ring, the haptic schedule and the completion callback — no race conditions between independent timers.
  • Pointer events are captured with a raw Listener (not GestureDetector) so the press starts immediately and the live finger position is available.
  • A single-pointer guard rejects secondary touches that would otherwise restart the animation.
  • Releasing early snaps the ring back to zero and resets the haptic cursor — a re-press starts fresh.

HapticToggle — animated switch with selection tick

HapticToggle(
  value: _enabled,
  onChanged: (v) => setState(() => _enabled = v),
)

HapticSlider — slider with detent ticks

HapticSlider(
  value: _v,
  min: 0,
  max: 100,
  divisions: 10,                              // tick every 10 units
  onChanged: (v) => setState(() => _v = v),
)

HapticStepper — bouncy −/+ counter

HapticStepper(
  value: _count,
  min: 0,
  max: 99,
  onChanged: (v) => setState(() => _count = v),
)

HapticShake — error wiggle

final shakeKey = GlobalKey<HapticShakeState>();

HapticShake(key: shakeKey, child: TextField(/* ... */));

// On validation failure:
shakeKey.currentState?.shake();

HapticPulse — looping attention pulse

The breathing counterpart to HapticShake: a looping minScale → maxScale pulse that fires a light impact on every beat. Use it to draw the eye to a CTA, an unread badge or a recording dot. It starts pulsing on mount by default:

HapticPulse(
  child: const Icon(Icons.notifications),
)

For manual control, set autoPlay: false and drive it via a GlobalKey:

final pulseKey = GlobalKey<HapticPulseState>();

HapticPulse(
  key: pulseKey,
  autoPlay: false,
  pulseCount: 3,          // stop after 3 beats; omit for an infinite pulse
  impactStyle: HapticImpactStyle.medium,
  child: const Icon(Icons.notifications),
);

pulseKey.currentState?.start();
// ...later:
pulseKey.currentState?.stop();

An infinite pulse never settles — in widget tests, drive the clock with tester.pump(duration) rather than tester.pumpAndSettle(), or pass a finite pulseCount.

SlideToConfirm — drag-to-confirm pill

SlideToConfirm(
  label: 'Slide to pay',
  onConfirmed: () => pay(),
)

Light ticks at 25%, 50%, 75% of drag, heavy thump on completion. Releasing before the end snaps back with a light tick.

HapticRating — cascading stars

HapticRating(
  value: _rating,
  starCount: 5,
  onChanged: (v) => setState(() => _rating = v),
)

Tapping the 4th star fires 4 selection ticks in sequence (one per star "lighting up"), driven by a Timer.periodic with a 65ms cascade delay.

Building your own widget

The widgets above are intentionally small (~100–200 lines each). To add a new one, follow this pattern:

  1. One file per widget in lib/src/widgets/your_widget.dart.
  2. Pick one of three pickers for what to do per gesture:
    • Tap — GestureDetector(onTapDown / onTapUp / onTap / onTapCancel) when you want the press-down + release lifecycle.
    • Long-press / hold — raw Listener so you get onPointerDown / onPointerUp / event.localPosition immediately and can implement single-pointer guards.
    • Drag — GestureDetector(onHorizontalDragUpdate / End) for anything slidey, or a draggable handle.
  3. One AnimationController per widget, driving everything that needs to stay in sync (visual change + haptic schedule + callbacks). Avoid running a Timer alongside an AnimationController — they drift, and the user feels the drift.
  4. Fire haptics from the addListener callback, gated by a "what was the last threshold I crossed" cursor (int _lastIndex, Set<double> _fired). while loops, not if, so a stuttered frame still fires every tick it crossed.
  5. Pick the right haptic for the moment — see the table below.
  6. Cancel cleanly: stop the controller, reset cursors, snap value back to zero. Atomic, in one method.
  7. Export from the barrel in lib/haptic_kit.dart.
  8. Write a widget test — see test/widgets_test.dart for the pattern (mock the channel with messenger.setMockMethodCallHandler).

Picking the right haptic

Moment Haptic Why
Crossing a discrete step (slider, picker, page) Haptics.selection() Quietest tap — never fatiguing
Press-down on a button Haptics.impact(light) Subtle "I felt your touch"
Release / tap completes Haptics.impact(medium) The "click"
Long-press completes / drag confirms Haptics.impact(heavy) Closes the loop with weight
Validation passed Haptics.notification(success) Two-tap pattern, recognisable
Soft error / boundary hit Haptics.notification(warning) Three-tap warning pattern
Hard error / wrong input Haptics.notification(error) Sharp triple-tap
Continuous waveform / heartbeat Vibration.vibrateWaveform(...) When duration matters more than crispness
Custom intensity + sharpness curve HapticPattern.builder()...play() Core Haptics on iOS, amplitude on Android

App-wide haptics switch

Most apps ship a "Haptics" toggle in their settings, and then wrap every call in the same if. Flip it once instead:

HapticSettings.enabled = preferences.hapticsEnabled;

// A no-op now, from anywhere, with no check at the call site
await Haptics.impact(HapticImpactStyle.light);
await VibrationPatterns.success();

While disabled, calls return normally and never reach the method channel, so an app whose user turned haptics off makes no platform calls at all.

Two things deliberately keep working while disabled:

  • Vibration.cancel() - refusing to cancel would leave the device buzzing after the user opted out.
  • capability queries - reading what a device supports is not playback.

This is the app's own preference. It does not read the system-level haptic setting, which the platforms apply themselves underneath.

Capability detection

// Cached: the platform is queried once, values cannot change at runtime
final caps = await HapticSettings.capabilities;

// Covers the device and the app preference in one check
if (await HapticSettings.isAvailable) {
  await VibrationPatterns.success();
}

HapticCapabilities.query() remains available for a deliberately fresh read:

final caps = await HapticCapabilities.query();
if (caps.supportsCustomPatterns) {
  await VibrationPatterns.success();
} else {
  await Haptics.notification(HapticNotificationStyle.success);
}

Error handling

All public APIs throw subclasses of VibrationException:

Exception Thrown when
InvalidVibrationArgumentException A parameter is out of range (negative duration, amplitude > 255, mismatched lists, …)
UnsupportedHapticException The device cannot render the requested capability
PlatformVibrationException The native side returned an error or the plugin is not registered

The animated widgets are the exception: their haptics are decorative, so they fire them in the background and absorb any VibrationException. On a device or platform that cannot play haptics (web, desktop, no vibrator) the widgets still animate and call their callbacks, just without the buzz, instead of raising an uncaught async error on every tap.

Example app

A runnable demo lives in example/ — buttons for every kind of feedback, side by side.

Haptics are invisible on video, so the example shows a small "haptic fired: ..." pill every time any haptic reaches the platform, including the ones the widgets fire internally. The play button in the app bar opens an auto-playing showcase that drives every widget with a scripted finger; the GIFs above are recorded from it (flutter run --dart-define=HAPTIC_KIT_SHOWCASE=true).

License

MIT — see LICENSE.

Libraries

haptic_kit
Comprehensive haptic feedback and vibration toolkit for Flutter — Android & iOS — combined with a set of production-ready animated widgets.