@mks2508/mks-ui
Hooks

FLIP Animation Hooks

Core hooks for building FLIP (First-Last-Invert-Play) animations with WAAPI.

FLIP Animation Hooks

Low-level hooks for building custom FLIP animations using the Web Animations API. These are the primitives that power the Reorder component.

Imports

import {
  useElementRegistry,
  usePositionCapture,
  useFLIPAnimation,
  useAnimationOrchestrator,
  // Constants
  TIMING,
  TRANSFORMS,
  EFFECTS,
  EASINGS,
  PRESETS,
  getResponsiveDuration,
  getResponsiveStagger,
} from '@mks2508/mks-ui/react';

Architecture

The FLIP system is composed of four layers:

useAnimationOrchestrator  ← High-level: 8-step FLIP sequence

        ├── useFLIPAnimation      ← Execute WAAPI animations

        ├── usePositionCapture    ← Calculate FLIP deltas

        └── useElementRegistry    ← Track DOM elements

useElementRegistry

Track DOM elements by ID for FLIP calculations.

const registry = useElementRegistry({
  onRegister: (id, element) => console.log(`Registered: ${id}`),
  onUnregister: (id) => console.log(`Unregistered: ${id}`),
});

// Register element
registry.register('item-1', document.getElementById('item-1'));

// API
registry.register(id, element);     // Register element
registry.unregister(id);            // Remove element
registry.get(id);                   // Get element by ID
registry.getAll();                  // Map<string, HTMLElement>
registry.has(id);                   // Check if registered
registry.clear();                   // Remove all
registry.size;                      // Count

Return Type

interface IElementRegistryAPI {
  register: (id: string, element: HTMLElement | null) => void;
  unregister: (id: string) => void;
  get: (id: string) => HTMLElement | undefined;
  getAll: () => Map<string, HTMLElement>;
  has: (id: string) => boolean;
  clear: () => void;
  size: number;
}

usePositionCapture

Capture element positions and calculate FLIP deltas.

const capture = usePositionCapture({
  threshold: TIMING.MOVEMENT_THRESHOLD, // Ignore movements < 2px
});

// Capture current positions of all registered elements
capture.capture(registry);

// Get individual position
const rect = capture.getPosition('item-1');

// Calculate delta between before/after
const delta = capture.calculateDelta('item-1', afterRect);
// { deltaX: 100, deltaY: 0, scaleX: 1, scaleY: 1 }

Return Type

interface IPositionCaptureAPI {
  capture: (registry: Map<string, HTMLElement>) => void;
  getPosition: (id: string) => IPositionRect | undefined;
  calculateDelta: (id: string, after: IPositionRect) => IFLIPDelta | null;
  getLastPositions: () => Map<string, IPositionRect>;
}

interface IFLIPDelta {
  deltaX: number;
  deltaY: number;
  scaleX: number;
  scaleY: number;
}

useFLIPAnimation

Execute FLIP animations using WAAPI with spring easing.

const flip = useFLIPAnimation();

// Animate single element
flip.animate(element, delta, {
  duration: 300,
  easing: EASINGS.MATERIAL.STANDARD,
});

// Animate multiple in parallel
flip.animateAll(
  new Map([['item-1', delta1], ['item-2', delta2]]),
  { duration: 300 }
);

// Cancel
flip.cancel('item-1');
flip.cancelAll();

// Check state
flip.isAnimating('item-1');

Return Type

interface IFLIPAnimationAPI {
  animate: (element: HTMLElement, delta: IFLIPDelta, options?: IAnimationTiming) => Animation;
  animateAll: (deltas: Map<string, IFLIPDelta>, options?: IAnimationTiming) => Animation[];
  cancel: (id: string) => void;
  cancelAll: () => void;
  isAnimating: (id?: string) => boolean;
}

useAnimationOrchestrator

High-level orchestrator composing all primitives into an 8-step FLIP sequence.

const orchestrator = useAnimationOrchestrator({
  enterDuration: 300,
  exitDuration: 200,
  flipDuration: 300,
  flipBehavior: 'siblings-after',
  exitPositionStrategy: 'absolute-fixed',
  onExitComplete: (id) => removeItem(id),
});

8-Step Exit Sequence

  1. Capture BEFORE positions of all elements
  2. Position:absolute on exiting element with parent compensation
  3. Synchronous reflow via getBoundingClientRect() (prevents flicker)
  4. Capture AFTER positions
  5. Calculate INVERT deltas with flipBehavior filtering
  6. Per-item stagger exit animation (opacity/scale/blur)
  7. Promise.all wait for all animations
  8. Cleanup remove positioning, call onExitComplete

Return Type

interface IAnimationOrchestratorAPI {
  registerElement: (id: string, element: HTMLElement | null) => void;
  unregisterElement: (id: string) => void;
  startExit: (id: string) => Promise<void>;
  startEnter: (id: string) => Promise<void>;
  isAnimating: (id?: string) => boolean;
  registry: Map<string, HTMLElement>;
}

Animation Constants

TIMING

TIMING = {
  ENTER_DURATION: 200,      // ms
  EXIT_DURATION: 180,       // ms
  FLIP_DURATION: 300,       // ms
  STAGGER_DELAY: 12,        // ms
  STAGGER_DELAY_MOBILE: 8,  // ms
  MOVEMENT_THRESHOLD: 2,    // px - ignore smaller movements
}

EASINGS

EASINGS = {
  MATERIAL: {
    STANDARD: 'cubic-bezier(0.4, 0, 0.2, 1)',
    DECELERATE: 'cubic-bezier(0, 0, 0.2, 1)',
    ACCELERATE: 'cubic-bezier(0.4, 0, 1, 1)',
    SHARP: 'cubic-bezier(0.4, 0, 0.6, 1)',
  },
  PHYSICS: {
    GENTLE: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
    BOUNCE: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
    ELASTIC: 'cubic-bezier(0.68, -0.6, 0.32, 1.6)',
  },
  SPRING: {
    DEFAULT: 'linear(0, 0.006, 0.024 2.1%, ...)',
    SNAPPY: 'linear(0, 0.003, 0.018 2.5%, ...)',
    GENTLE: 'linear(0, 0.005, 0.025 2.8%, ...)',
  },
}

Responsive Helpers

// Reduce duration for reduced motion or mobile
getResponsiveDuration(300, { reducedMotion: true, isMobile: false });
// => 150 (50% for reduced motion)

getResponsiveDuration(300, { reducedMotion: false, isMobile: true });
// => 180 (60% for mobile)

getResponsiveStagger(20, true);
// => 10 (50% for mobile)

Building Custom Animations

Example: Custom Staggered Exit

function useStaggeredExit(registry: Map<string, HTMLElement>) {
  const capture = usePositionCapture();
  const flip = useFLIPAnimation();

  const exitAll = async (ids: string[], staggerMs = 20) => {
    // 1. Capture positions
    capture.capture(registry);

    // 2. Execute staggered exits
    const animations: Promise<void>[] = [];

    ids.forEach((id, index) => {
      const element = registry.get(id);
      if (!element) return;

      // Delay start based on index
      setTimeout(() => {
        const animation = element.animate(
          { opacity: [1, 0], transform: ['scale(1)', 'scale(0.85)'] },
          { duration: 200, easing: EASINGS.MATERIAL.DECELERATE }
        );
        animations.push(animation.finished);
      }, index * staggerMs);
    });

    await Promise.all(animations);
  };

  return { exitAll };
}