CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-popmotion

The animator's toolbox providing comprehensive animation capabilities including keyframe, spring, and decay animations for numbers, colors, and complex strings

Pending

Quality

Pending

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Eval results
Files

inertia.mddocs/

Inertia Animation

Specialized inertia animation that combines decay with spring physics for scroll-like behaviors with boundaries. Perfect for implementing momentum scrolling, drag interactions, and physics-based UI elements.

Capabilities

Inertia Function

Creates an inertia-based animation that uses decay motion with spring boundaries.

/**
 * Creates an inertia animation with decay and boundary springs
 * @param options - Inertia configuration including boundaries and physics
 * @returns PlaybackControls for stopping the animation
 */
function inertia(options: InertiaOptions): PlaybackControls;

interface InertiaOptions extends DecayOptions {
  /** Spring stiffness when hitting boundaries */
  bounceStiffness?: number;
  /** Spring damping when hitting boundaries */
  bounceDamping?: number;
  /** Minimum boundary value */
  min?: number;
  /** Maximum boundary value */
  max?: number;
  /** Speed threshold for completion detection */
  restSpeed?: number;
  /** Distance threshold for completion detection */
  restDelta?: number;
  /** Custom driver for animation timing */
  driver?: Driver;
  /** Called with latest value on each frame */
  onUpdate?: (v: number) => void;
  /** Called when animation completes */
  onComplete?: () => void;
  /** Called when animation is stopped */
  onStop?: () => void;
}

interface DecayOptions {
  /** Starting value */
  from?: number;
  /** Target value (optional, not used in decay calculation) */
  to?: number;
  /** Initial velocity */
  velocity?: number;
  /** Decay power factor (default: 0.8) */
  power?: number;
  /** Time constant for decay rate */
  timeConstant?: number;
  /** Function to modify calculated target */
  modifyTarget?: (target: number) => number;
  /** Distance threshold for completion */
  restDelta?: number;
}

interface PlaybackControls {
  /** Stop the animation immediately */
  stop: () => void;
}

Usage Examples:

import { inertia } from "popmotion";

// Basic momentum scrolling
let scrollY = 0;

inertia({
  from: scrollY,
  velocity: -800, // Initial upward velocity
  min: 0,
  max: maxScrollHeight,
  power: 0.8,
  timeConstant: 750, // Default value
  bounceStiffness: 500, // Default value
  bounceDamping: 10, // Default value
  onUpdate: (y) => {
    scrollY = y;
    scrollContainer.scrollTop = y;
  },
  onComplete: () => console.log("Scrolling stopped")
});

// Horizontal drag with boundaries
const controls = inertia({
  from: currentX,
  velocity: dragVelocity,
  min: 0,
  max: containerWidth - elementWidth,
  power: 0.9,
  bounceStiffness: 400,
  bounceDamping: 30,
  onUpdate: (x) => {
    element.style.transform = `translateX(${x}px)`;
  }
});

// Stop animation on user interaction
element.addEventListener('pointerdown', () => controls.stop());

Behavior Characteristics

Inertia animation combines two physics models:

  1. Decay Phase: When within boundaries, uses exponential decay
  2. Spring Phase: When beyond boundaries, uses spring physics to bounce back
// Example of combined behavior
inertia({
  from: 50,
  velocity: 1000,  // Fast rightward motion
  min: 0,
  max: 100,
  
  // Decay parameters (used within boundaries)
  power: 0.8,
  timeConstant: 325,
  
  // Spring parameters (used at boundaries)
  bounceStiffness: 300,
  bounceDamping: 40,
  
  onUpdate: (value) => {
    // Animation will:
    // 1. Decay from 50 towards calculated target (~200)
    // 2. Hit max boundary (100) and spring back
    // 3. Settle within 0-100 range
    console.log(value);
  }
});

Implementation Patterns

Momentum Scrolling

import { inertia } from "popmotion";

class MomentumScroller {
  private currentY = 0;
  private maxScroll: number;
  private animation?: PlaybackControls;
  
  constructor(private element: HTMLElement) {
    this.maxScroll = element.scrollHeight - element.clientHeight;
    this.setupEventListeners();
  }
  
  private setupEventListeners() {
    let startY = 0;
    let lastY = 0;
    let velocity = 0;
    
    this.element.addEventListener('pointerdown', (e) => {
      this.stopAnimation();
      startY = e.clientY;
      lastY = e.clientY;
      velocity = 0;
    });
    
    this.element.addEventListener('pointermove', (e) => {
      if (startY === 0) return;
      
      const currentY = e.clientY;
      const delta = currentY - lastY;
      
      // Calculate velocity (with time consideration)
      velocity = delta * 60; // Approximate pixels per second
      
      this.currentY = Math.max(0, Math.min(this.maxScroll, 
        this.currentY - delta
      ));
      
      this.updateScroll();
      lastY = currentY;
    });
    
    this.element.addEventListener('pointerup', () => {
      if (Math.abs(velocity) > 100) {
        this.startInertia(velocity);
      }
      startY = 0;
    });
  }
  
  private startInertia(velocity: number) {
    this.animation = inertia({
      from: this.currentY,
      velocity: -velocity, // Invert for scroll direction
      min: 0,
      max: this.maxScroll,
      power: 0.8,
      timeConstant: 325,
      bounceStiffness: 300,
      bounceDamping: 40,
      onUpdate: (y) => {
        this.currentY = y;
        this.updateScroll();
      }
    });
  }
  
  private updateScroll() {
    this.element.scrollTop = this.currentY;
  }
  
  private stopAnimation() {
    this.animation?.stop();
    this.animation = undefined;
  }
}

Draggable Element with Boundaries

import { inertia } from "popmotion";

class BoundedDraggable {
  private currentX = 0;
  private currentY = 0;
  private animation?: PlaybackControls;
  
  constructor(
    private element: HTMLElement,
    private bounds: { left: number; right: number; top: number; bottom: number }
  ) {
    this.setupDragHandlers();
  }
  
  private setupDragHandlers() {
    let startX = 0;
    let startY = 0;
    let velocityX = 0;
    let velocityY = 0;
    let lastTime = Date.now();
    
    this.element.addEventListener('pointerdown', (e) => {
      this.stopAnimation();
      startX = e.clientX - this.currentX;
      startY = e.clientY - this.currentY;
      lastTime = Date.now();
    });
    
    this.element.addEventListener('pointermove', (e) => {
      if (startX === 0 && startY === 0) return;
      
      const now = Date.now();
      const deltaTime = now - lastTime;
      
      const newX = e.clientX - startX;
      const newY = e.clientY - startY;
      
      // Calculate velocity
      velocityX = (newX - this.currentX) / deltaTime * 1000;
      velocityY = (newY - this.currentY) / deltaTime * 1000;
      
      this.currentX = newX;
      this.currentY = newY;
      this.updatePosition();
      
      lastTime = now;
    });
    
    this.element.addEventListener('pointerup', () => {
      if (Math.abs(velocityX) > 50 || Math.abs(velocityY) > 50) {
        this.startInertia(velocityX, velocityY);
      }
      startX = startY = 0;
    });
  }
  
  private startInertia(velocityX: number, velocityY: number) {
    // Start separate inertia for X and Y axes
    const xAnimation = inertia({
      from: this.currentX,
      velocity: velocityX,
      min: this.bounds.left,
      max: this.bounds.right,
      power: 0.8,
      bounceStiffness: 400,
      bounceDamping: 40,
      onUpdate: (x) => {
        this.currentX = x;
        this.updatePosition();
      }
    });
    
    const yAnimation = inertia({
      from: this.currentY,
      velocity: velocityY,
      min: this.bounds.top,
      max: this.bounds.bottom,
      power: 0.8,
      bounceStiffness: 400,
      bounceDamping: 40,
      onUpdate: (y) => {
        this.currentY = y;
        this.updatePosition();
      }
    });
    
    // Store reference to stop both animations
    this.animation = {
      stop: () => {
        xAnimation.stop();
        yAnimation.stop();
      }
    };
  }
  
  private updatePosition() {
    this.element.style.transform = 
      `translate(${this.currentX}px, ${this.currentY}px)`;
  }
  
  private stopAnimation() {
    this.animation?.stop();
    this.animation = undefined;
  }
}

Parameter Guidelines

Decay Parameters

  • Power: 0.7-0.9 (higher = slower decay, more coasting) - Default: 0.8
  • TimeConstant: 200-800ms (higher = longer momentum) - Default: 750
  • Velocity: Measured in pixels/second from user interaction

Bounce Parameters

  • BounceStiffness: 200-600 (higher = snappier bounce back) - Default: 500
  • BounceDamping: 10-60 (higher = less oscillation) - Default: 10
  • RestSpeed: 0.01-1 (threshold for stopping)
  • RestDelta: 0.01-1 (distance threshold for stopping)

Boundary Configuration

  • Min/Max: Set appropriate boundaries based on content size
  • Use modifyTarget to implement custom boundary logic
  • Consider different bounce characteristics for different boundaries

Install with Tessl CLI

npx tessl i tessl/npm-popmotion

docs

animation-generators.md

core-animation.md

easing.md

index.md

inertia.md

utilities.md

tile.json