haptic_kit
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.
Previously developed under the names
flutter_vibration_animationandflutter_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,HapticPatternandVibrationPatterns.
Features
Haptics— short, semantic taps (namedHapticsto avoid clashing with Flutter's ownHapticFeedback)- 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 (returnstrueon iOS,falseno-op on Android)
- Impact:
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
intensityandsharpness(0.0–1.0) - Automatic translation to Android amplitude waveforms
VibrationPatterns— ready-made: heartbeat, notification, alarm, tick, success, failure, charge-upHapticCapabilities— runtime detection of vibrator hardware, amplitude control, Core Haptics, predefined effectsHapticBounce— drop-in tap wrapper with squash + recoil + elastic settle bounce (3-segmentTweenSequence), wired to light/medium impactPressAndHoldToConfirm— long-press confirmation with a finger-tracking progress ring and a 12-tick densifying haptic schedule that escalates fromselection→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+ |
| Capability detection | ✅ | ✅ |
Installation
dependencies:
haptic_kit: ^1.0.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
CHHapticPatternwithhapticTransient/hapticContinuousevents. - On Android (API 26+)
intensityis mapped to amplitude;sharpnessis ignored (no perceptual analogue). - On older devices,
play()throwsUnsupportedHapticException— guard withHapticCapabilities.query()if you need graceful degradation.
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.
| 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:
- squash —
1.0 → 0.92,easeIn - recoil —
0.92 → 1.12(overshoots 1.0),easeOutCubic - 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
AnimationControllerdrives the ring, the haptic schedule and the completion callback — no race conditions between independent timers. - Pointer events are captured with a raw
Listener(notGestureDetector) 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 thantester.pumpAndSettle(), or pass a finitepulseCount.
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:
- One file per widget in
lib/src/widgets/your_widget.dart. - 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
Listenerso you getonPointerDown/onPointerUp/event.localPositionimmediately and can implement single-pointer guards. - Drag —
GestureDetector(onHorizontalDragUpdate / End)for anything slidey, or a draggable handle.
- Tap —
- One
AnimationControllerper widget, driving everything that needs to stay in sync (visual change + haptic schedule + callbacks). Avoid running aTimeralongside anAnimationController— they drift, and the user feels the drift. - Fire haptics from the
addListenercallback, gated by a "what was the last threshold I crossed" cursor (int _lastIndex,Set<double> _fired).whileloops, notif, so a stuttered frame still fires every tick it crossed. - Pick the right haptic for the moment — see the table below.
- Cancel cleanly: stop the controller, reset cursors, snap value back to zero. Atomic, in one method.
- Export from the barrel in
lib/haptic_kit.dart. - Write a widget test — see
test/widgets_test.dartfor the pattern (mock the channel withmessenger.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 |
Example app
A runnable demo lives in example/ — buttons for every kind of
feedback, side by side.
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.