Gestures

So far, NibMotion’s initial, animate, and exit props describe animations driven by your own state — a flag flips, you call setState, and NibMotion tweens from one NibAnim to the next. But some of the most satisfying motion in an interface is driven by the user’s pointer directly: a button that visibly compresses under a tap, a card that lifts when you hover over it, or a panel you can drag around the screen and let go of.

NibMotion covers all of this with four gesture-reactive props — whileTap, whileHover, whileFocus, and whileDrag — plus a drag prop that turns on full drag-and-release handling. None of this requires wrapping your widget in a GestureDetector or managing animation controllers yourself; NibMotion wires up the recognizers and runs the animations for you.

The while* props

Each while* prop takes a NibAnim — exactly like animate — but it’s only applied while the corresponding interaction state is active. The moment the interaction ends, NibMotion animates back toward animate (or initial, if animate is unset).

PropActive while…
whileTapthe pointer is pressed down on the widget
whileHoverthe pointer is hovering over the widget (desktop/web)
whileFocusthe widget has keyboard focus
whileDragthe widget is being dragged (see Drag)

If more than one of these states is active at once, NibMotion picks a single winner using the priority drag > tap > hover > focus. So a widget that’s both hovered and pressed animates toward whileTap, not whileHover — and a widget that’s being dragged ignores whileTap/whileHover/whileFocus entirely until the drag ends.

Just like animate, a while* NibAnim only needs to describe the properties that change. Anything you don’t set keeps its current value, so whileTap: NibAnim(scale: 0.96) shrinks the widget slightly without disturbing its position, opacity, or color.

whileTap: a button that compresses on press

A common micro-interaction: scale a button down slightly the instant it’s pressed, and back up the instant it’s released.

import 'package:flutter/material.dart';
import 'package:nib_motion/nib_motion.dart';
 
class PressableButton extends StatelessWidget {
  const PressableButton({super.key, required this.onPressed});
 
  final VoidCallback onPressed;
 
  @override
  Widget build(BuildContext context) {
    return NibMotion(
      whileTap: const NibAnim(scale: 0.96),
      transition: const NibTransition(duration: Duration(milliseconds: 100)),
      child: GestureDetector(
        onTap: onPressed,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.primary,
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(
            'Continue',
            style: TextStyle(
              color: Theme.of(context).colorScheme.onPrimary,
              fontWeight: FontWeight.w600,
            ),
          ),
        ),
      ),
    );
  }
}

NibMotion detects the press via its own internal tap recognizer — it doesn’t need your GestureDetector’s onTap to fire first. The instant the pointer goes down, the widget animates to scale: 0.96; the instant it lifts (or the gesture is cancelled), it animates back to its resting scale of 1.0. A short transition.duration like 100ms keeps the squash feeling immediate rather than springy.

whileHover: a card that lifts on hover

On desktop and web, whileHover is the natural home for “lift” effects — nudging a card upward and deepening its shadow to suggest it’s now interactive.

import 'package:flutter/material.dart';
import 'package:nib_motion/nib_motion.dart';
 
class LiftingCard extends StatelessWidget {
  const LiftingCard({super.key, required this.title});
 
  final String title;
 
  @override
  Widget build(BuildContext context) {
    return NibMotion(
      whileHover: NibAnim(
        y: -4,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.18),
            blurRadius: 16,
            offset: const Offset(0, 8),
          ),
        ],
      ),
      transition: const NibTransition(duration: Duration(milliseconds: 180)),
      child: Container(
        width: 220,
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.surface,
          borderRadius: BorderRadius.circular(12),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.06),
              blurRadius: 8,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Text(title, style: Theme.of(context).textTheme.titleMedium),
      ),
    );
  }
}

Give the card’s resting state its own boxShadow (set directly on the Container’s decoration, as above) so the hover transition reads as a deepening of an existing shadow rather than a shadow appearing from nothing. NibMotion interpolates boxShadow lists the same way it interpolates colors and offsets — element by element.

whileFocus works identically to whileHover, but is driven by keyboard focus instead of pointer position — useful for showing a focus ring or subtle glow on form fields without writing your own Focus/FocusNode listener.

Drag

Set the drag prop to a NibDragConfig to let the user pick up and move a NibMotion with their finger or pointer. While the drag is active, the widget’s translation follows the pointer directly (subject to axis and constraints), and whileDrag — if set — describes how the rest of the widget should look while that’s happening (e.g. a slight scale-up or shadow, to suggest the item has been “picked up”).

NibMotion(
  drag: const NibDragConfig(
    axis: Axis.horizontal,
    constraints: Rect.fromLTRB(-120, 0, 120, 0),
  ),
  whileDrag: const NibAnim(scale: 1.05),
  child: const ChipWidget(),
)

axis

axis locks dragging to a single direction. Axis.horizontal zeroes out all vertical movement; Axis.vertical zeroes out horizontal movement. Leave it null (the default) to allow free dragging in both directions — useful for draggable cards, map-style panning, or anything that isn’t meant to slide along a single rail.

constraints

constraints is a Rect describing the bounding box the drag offset is confined to, relative to the widget’s position before the drag started — not screen coordinates. So Rect.fromLTRB(-120, 0, 120, 0) means “this widget can move up to 120 logical pixels left or right of where it started, but can’t move vertically at all” (and would normally be paired with axis: Axis.horizontal to make that vertical lock explicit).

If constraints is null (the default), the drag offset is unbounded.

elastic and elasticFactor: rubber-band resistance

By default, once the drag offset reaches the edge of constraints, it’s clamped — the widget simply stops moving, even if the pointer keeps going. Setting elastic: true changes this to rubber-band resistance: the widget keeps moving past the constraint, but at a fraction of the pointer’s actual movement, so it feels like it’s being stretched against a spring.

elasticFactor (default 0.2) controls how much of the over-the-edge movement gets through — the expected range is 0.10.5. Smaller values feel stiffer (more resistance); larger values feel looser.

NibMotion(
  drag: const NibDragConfig(
    axis: Axis.horizontal,
    constraints: Rect.fromLTRB(-80, 0, 80, 0),
    elastic: true,
    elasticFactor: 0.25,
  ),
  whileDrag: const NibAnim(scale: 1.03),
  child: const PillToggle(),
)

With elastic: true and the default releaseMode: NibDragReleaseMode.springBack, dragging past either edge of constraints stretches the widget a bit further than the limit, and releasing snaps it back inside the bounds — the classic “pull-to-refresh” feel.

onDragStart and onDragEnd

onDragStart is a VoidCallback fired the moment a drag gesture begins — useful for triggering a whileDrag-style state change elsewhere in your UI, pausing other animations, or just logging analytics.

onDragEnd is called once when the drag gesture ends, with the release Velocity (from package:flutter/gestures.dart). This is the value that determines how NibDragReleaseMode.inertia coasts — you can also read it yourself to decide, for example, whether a swipe was fast enough to count as a “dismiss” versus a gentle nudge that should spring back.

NibMotion(
  drag: NibDragConfig(
    axis: Axis.horizontal,
    onDragStart: () => print('drag started'),
    onDragEnd: (velocity) => print('released at ${velocity.pixelsPerSecond}'),
  ),
  child: const DraggableTile(),
)

releaseMode: how the widget settles

Once the pointer lifts, releaseMode (a NibDragReleaseMode) decides what happens next — unless snapPoints is set and non-empty, in which case snapPoints wins and releaseMode is ignored entirely.

springBack: return to the start

The default. The widget animates back to the position it was at before the drag started, as if pulled back by a spring. This is the right choice for anything that should feel like it was only “borrowed” — a card you can nudge to peek at what’s behind it, then let go to put it back.

NibMotion(
  drag: const NibDragConfig(
    releaseMode: NibDragReleaseMode.springBack,
  ),
  whileDrag: const NibAnim(scale: 1.04),
  transition: const NibTransition(
    duration: Duration(milliseconds: 300),
    curve: Curves.easeOutBack,
  ),
  child: const PeekCard(),
)

Drag the card anywhere, release, and it animates back to exactly where it started — Curves.easeOutBack gives the return a small, satisfying overshoot.

inertia: coast to a natural stop

The widget keeps moving in the direction (and roughly the speed) it was released, gradually slowing to a stop — driven by the release Velocity. This is the feel behind swipe-to-dismiss: a fast flick sends the widget flying offscreen, while a slow drag-and-release barely moves it further.

NibMotion(
  drag: const NibDragConfig(
    axis: Axis.horizontal,
    releaseMode: NibDragReleaseMode.inertia,
  ),
  whileDrag: const NibAnim(scale: 0.98),
  child: const DismissibleNotification(),
)

Pair inertia with an onDragEnd check on the release velocity if you want to trigger a side effect — such as actually removing the item from a list — once it’s clear the widget coasted far enough offscreen to count as dismissed.

stay: leave it where it was dropped

The simplest mode: whatever offset the widget had when the pointer lifted is where it stays. No spring-back, no coasting. This suits free-form arrangements — sticky notes, a moodboard, anything where the user is intentionally repositioning something permanently (within a session).

NibMotion(
  drag: const NibDragConfig(
    releaseMode: NibDragReleaseMode.stay,
  ),
  child: const StickyNote(),
)

snapPoints: snap to the nearest of several positions

snapPoints is a List<Offset> of candidate resting positions, expressed as offsets from the widget’s starting position — the same coordinate space as constraints. If snapPoints is set and non-empty, releasing the drag animates the widget to whichever point in the list is closest to where it was released, regardless of releaseMode.

This is the building block for bottom sheets, paginated carousels, and toggle-style switches — anywhere the widget should always come to rest at one of a small, known set of positions.

Putting it together: a draggable bottom sheet

Combining drag, whileDrag, constraints, and snapPoints produces a bottom-sheet-style card that can be dragged between a “peek” position and fully open, and always snaps cleanly to one or the other on release:

import 'package:flutter/material.dart';
import 'package:nib_motion/nib_motion.dart';
 
class SnapSheet extends StatelessWidget {
  const SnapSheet({super.key});
 
  static const _peekOffset = Offset(0, 320);
  static const _openOffset = Offset.zero;
 
  @override
  Widget build(BuildContext context) {
    return NibMotion(
      drag: const NibDragConfig(
        axis: Axis.vertical,
        constraints: Rect.fromLTRB(0, 0, 0, 320),
        snapPoints: [_openOffset, _peekOffset],
        releaseMode: NibDragReleaseMode.springBack,
      ),
      whileDrag: const NibAnim(
        boxShadow: [
          BoxShadow(color: Colors.black26, blurRadius: 24, offset: Offset(0, -4)),
        ],
      ),
      transition: const NibTransition(
        duration: Duration(milliseconds: 280),
        curve: Curves.easeOutCubic,
      ),
      child: Container(
        height: 360,
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.surface,
          borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
        ),
        child: Column(
          children: [
            Container(
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: Theme.of(context).colorScheme.outlineVariant,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
            const SizedBox(height: 16),
            Text('Drag me up or down',
                style: Theme.of(context).textTheme.titleMedium),
          ],
        ),
      ),
    );
  }
}

The sheet starts at _openOffset (Offset.zero). constraints keeps the drag within 0 to 320 logical pixels of vertical travel, axis: vertical ignores any horizontal movement, and snapPoints lists the two valid resting positions — fully open and “peeked” 320px down. Wherever the pointer is released, NibMotion animates to whichever of _openOffset/_peekOffset is closer, using the transition curve and duration above; releaseMode is set for clarity but is overridden by snapPoints whenever it’s non-empty. whileDrag adds a shadow above the sheet only while it’s actively being dragged, reinforcing that it’s been “lifted” off the layout below it.

The building block: DragHandler and applyElastic

Everything above is NibMotion driving its own internal DragHandler — but if you’re building a fully custom widget with your own GestureDetector and want the same axis-locking, constraint, and elastic behavior without NibMotion’s animation machinery, DragHandler is the standalone class that does exactly that. Construct it with a NibDragConfig plus onUpdate and onRelease callbacks, then forward your GestureDetector’s onPanStart/onPanUpdate/onPanEnd straight into handlePanStart/handlePanUpdate/handlePanEndDragHandler tracks the running offset and release velocity for you, applying axis, constraints, and elastic/elasticFactor exactly as described above. The free function applyElastic(value, min, max, factor) is the rubber-band math itself, exported separately in case you only need that one calculation.

API reference

NibDragConfig

PropTypeDefaultDescription
axisAxis?nullLocks dragging to a single axis. null allows free dragging in both directions.
constraintsRect?nullThe bounding box the drag offset is confined to, relative to the widget’s position before the drag started. null means unconstrained.
elasticboolfalseWhether dragging past constraints applies rubber-band resistance instead of clamping outright.
elasticFactordouble0.2How much of the drag past constraints is applied when elastic is true. Expected range 0.10.5; smaller values are stiffer.
onDragStartVoidCallback?nullCalled when a drag gesture begins.
onDragEndvoid Function(Velocity velocity)?nullCalled when a drag gesture ends, with the release velocity.
releaseModeNibDragReleaseModeNibDragReleaseMode.springBackHow the widget settles once the drag gesture ends. Ignored if snapPoints is set and non-empty.
snapPointsList<Offset>?nullIf set and non-empty, the widget springs to the nearest of these points on release instead of following releaseMode.

NibDragReleaseMode

ValueDescription
springBackSpring back to the position the widget was at before the drag started.
inertiaCoast to a natural stop using the release velocity.
stayStay exactly where the drag left it.

DragHandler

MethodSignatureDescription
(constructor)DragHandler({required NibDragConfig config, required ValueChanged<Offset> onUpdate, required void Function(Offset offset, Velocity velocity) onRelease})Creates a handler driven by config, reporting position updates via onUpdate and the release state via onRelease.
offsetOffset (getter)The current drag offset relative to the gesture’s start.
handlePanStartvoid handlePanStart(DragStartDetails details)Call from GestureDetector.onPanStart. Resets offset and calls config.onDragStart.
handlePanUpdatevoid handlePanUpdate(DragUpdateDetails details)Call from GestureDetector.onPanUpdate. Applies axis locking and constraints/elastic, then calls onUpdate with the new offset.
handlePanEndvoid handlePanEnd(DragEndDetails details)Call from GestureDetector.onPanEnd. Calls onRelease with the final offset and release velocity.

applyElastic

MethodSignatureDescription
applyElasticdouble applyElastic(double value, double min, double max, double factor)Applies rubber-band resistance to value once it passes min or max, scaled by factor.

Next steps

  • Variantswhile* props and drag work alongside variants, so a single named state (like "selected") can drive layout, color, and what happens on tap or drag, all from one place.
  • Motion Controller — for gestures that should trigger an animation on a different widget (not just the one being tapped or dragged), drive that animation from a MotionController instead of a while* prop.