Animation

Physics-based springs, tweens, transitions, and gesture detection.

spring(initialValue, options)

Creates a physics-based spring animation. Springs produce natural, fluid motion by simulating a damped harmonic oscillator. Unlike time-based animations, springs respond dynamically when interrupted: you can redirect a spring mid-flight and it will smoothly change course.

import { spring } from 'what-framework';

// Create a spring starting at 0
const position = spring(0, {
  stiffness: 100,   // Spring tension (default: 100)
  damping: 10,      // Friction (default: 10)
  mass: 1,          // Weight of the object (default: 1)
  precision: 0.01,  // Settle threshold (default: 0.01)
});

// Animate to a new value
position.set(200);

// Read current animated value (reactive)
console.log(position.current());

// Check animation state
console.log(position.isAnimating());  // true while moving
console.log(position.velocity());     // current speed

The returned object exposes these reactive accessors and methods:

  • current(): the current animated value (reactive signal)
  • target(): the value the spring is heading toward
  • velocity(): the current velocity of the animation
  • isAnimating(): whether the spring is still in motion
  • set(target): start animating toward a new target
  • stop(): halt the animation at its current position
  • snap(value): immediately jump to a value (no animation)
  • subscribe(fn) to react to the current value directly

Automatic Cleanup (springs only)

When a spring() is created inside a component it registers its stop() as a cleanup callback, so its requestAnimationFrame loop is cancelled when the component unmounts. This applies to spring() alone. tween() registers nothing with the component lifecycle, so cancel it yourself (see below).

Example: Animated Counter

A counter that springs smoothly between values instead of jumping:

import { signal, effect, spring } from 'what-framework';

function AnimatedCounter() {
  const count = signal(0);
  const display = spring(0, { stiffness: 120, damping: 14 });

  // When count changes, animate the display value
  effect(() => display.set(count()));

  return (
    <div>
      <span>{() => Math.round(display.current())}</span>
      <button onClick={() => count.set(c => c + 10)}>+10</button>
    </div>
  );
}

tween(from, to, options)

Creates a time-based animation that interpolates from one value to another over a fixed duration. Unlike springs, tweens have a predictable end time and use easing functions to control their curve.

A tween starts the moment it is created. There is no play() call: constructing it arms the first frame, and isAnimating() is already true before that frame runs. Create it at the point you want the motion to begin.

import { tween, easings } from 'what-framework';

// Animate from 0 to 100 over 500ms, running as soon as this line executes
const anim = tween(0, 100, {
  duration: 500,                    // milliseconds (default: 300)
  easing: easings.easeOutCubic,     // easing function (default: easeOutQuad)
  onUpdate: (value, t) => {},       // called each frame
  onComplete: () => {},             // called when finished
});

// Reactive accessors
anim.progress();     // 0 to 1, raw progress
anim.value();        // interpolated value (0 to 100)
anim.isAnimating();  // true from creation until the last frame

// Subscribe directly to the interpolated value
anim.subscribe((value) => {});

// Cancel mid-animation
anim.cancel();

Tweens are not cleaned up for you

Unlike spring(), a tween() created in a component body registers nothing with that component's lifecycle. Its frame loop keeps running, and keeps writing its signals, after the component unmounts. Pair every component-scoped tween with onCleanup(() => anim.cancel()).

Example: Progress Bar

Animate a progress bar from 0% to 100% with a bounce easing:

import { tween, easings, onCleanup } from 'what-framework';

function ProgressBar() {
  // Starts filling immediately
  const bar = tween(0, 100, {
    duration: 1200,
    easing: easings.easeOutBounce,
  });

  onCleanup(() => bar.cancel());

  return (
    <div class="progress-track">
      <div
        class="progress-fill"
        style={() => `width: ${bar.value()}%`}
      />
    </div>
  );
}

Easing Functions

The easings object provides a set of common easing curves. Each function maps an input t (0 to 1) to an output value:

import { easings } from 'what-framework';

// Available easings:
easings.linear          // constant speed
easings.easeInQuad      // accelerate from zero
easings.easeOutQuad     // decelerate to zero
easings.easeInOutQuad   // accelerate then decelerate
easings.easeInCubic     // stronger acceleration
easings.easeOutCubic    // stronger deceleration
easings.easeInOutCubic  // stronger ease in/out
easings.easeInElastic   // elastic wind-up
easings.easeOutElastic  // elastic overshoot
easings.easeOutBounce   // bouncing settle

Pass any easing to tween() or useTransition():

const anim = tween(0, 100, { easing: easings.easeOutBounce });

// Or use a custom easing function
const customEase = (t) => t * t * t;
const anim2 = tween(0, 100, { easing: customEase });

useTransition

Orchestrate state transitions with a progress signal. Useful for coordinating page transitions, view swaps, or multi-step animations where you need to know when the transition completes.

import { useTransition, easings } from 'what-framework';

const transition = useTransition({
  duration: 400,                  // ms (default: 300)
  easing: easings.easeOutCubic,   // easing curve (default: easeOutQuad)
});

// Reactive state
transition.isTransitioning();  // true during animation
transition.progress();         // 0 to 1 (eased)

// Start a transition. Returns a Promise.
await transition.start(() => {
  // Callback runs when transition completes
  swapContent();
});

progress() stays at 1 when the transition ends

start() resets progress() to 0, ramps it to 1, then resolves. Nothing runs it back down, so after the promise settles progress() is still 1. Binding an opacity to a bare 1 - progress() therefore leaves the element permanently at 0. Gate the binding on isTransitioning(), which is already back to false by the time your callback runs.

Example: Page Transition

import { signal, useTransition } from 'what-framework';

function PageContainer() {
  const currentPage = signal('home');
  const fade = useTransition({ duration: 250 });

  async function navigate(page) {
    // Fades out over 250ms, then swaps the page
    await fade.start(() => currentPage.set(page));
  }

  return (
    <div style={() => `opacity: ${fade.isTransitioning() ? 1 - fade.progress() : 1}`}>
      <nav>
        <button onClick={() => navigate('home')}>Home</button>
        <button onClick={() => navigate('about')}>About</button>
      </nav>
      <h1>{() => currentPage()}</h1>
    </div>
  );
}

useGesture

Attach multi-touch gesture handlers to an element. Supports drag, swipe, pinch, tap, and long press with velocity tracking. Works with both mouse and touch events.

useGesture needs a live DOM element, not a ref placeholder. Capture the node with a callback ref and call the hook from onMount, which runs after the element exists:

import { useGesture, onMount } from 'what-framework';

function Card() {
  let el;

  onMount(() => {
    useGesture(el, {
      onDragStart: ({ x, y }) => {},
      onDrag: ({ x, y, deltaX, deltaY, velocity }) => {},
      onDragEnd: ({ deltaX, deltaY, velocity }) => {},
      onSwipe: ({ direction, velocity }) => {},
      onPinch: ({ scale, centerX, centerY }) => {},
      onTap: ({ x, y }) => {},
      onLongPress: ({ x, y }) => {},
      preventDefault: false,
    });
  });

  return <div ref={(node) => { el = node; }}>Card</div>;
}

Don't pass a ref placeholder

useGesture({ current: null }, handlers) silently attaches nothing. A plain { current } object is not a signal, so the internal effect that reads it runs once during the component body, before the element exists, sees null, and never re-runs. There is no error and no warning, the handlers simply never fire. Pass the element itself.

Gesture listeners are not cleaned up for you

Passing the element registers no cleanup. useGesture returns its gesture state and nothing else, so the two listeners it adds to the element and the six it adds to window are still attached after the component unmounts. Use it on elements that live as long as the page, and expect a leak from a component that mounts and unmounts repeatedly.

The returned state object provides reactive signals:

  • isDragging(): whether a drag is in progress
  • currentX(), currentY(): current pointer position
  • deltaX(), deltaY(): distance from drag start
  • velocity(): current velocity as { x, y } in px/sec

Example: Draggable Card

import { useGesture, spring, onMount } from 'what-framework';

function DraggableCard() {
  let el;
  const x = spring(0);
  const y = spring(0);

  onMount(() => {
    useGesture(el, {
      onDrag: ({ deltaX, deltaY }) => {
        x.snap(deltaX);
        y.snap(deltaY);
      },
      onDragEnd: () => {
        // Spring back to origin
        x.set(0);
        y.set(0);
      },
    });
  });

  return (
    <div
      ref={(node) => { el = node; }}
      style={() => `transform: translate(${x.current()}px, ${y.current()}px)`}
      class="card"
    >
      Drag me!
    </div>
  );
}

Touch Events and Scrolling

The element's touchstart listener is registered with { passive: true } so it never blocks scroll. Setting preventDefault: true flips that one listener to non-passive, which is all the option does. The touchmove listeners are attached to window without options, so they follow the browser's default (passive at window level) and e.preventDefault() inside onDrag will not stop the page scrolling. For a draggable element, set touch-action: none on it in CSS instead.

useAnimatedValue

A React Native-inspired API that wraps a signal with animation methods. Useful when you need to drive multiple animations from a single value or interpolate between ranges.

import { useAnimatedValue } from 'what-framework';

const progress = useAnimatedValue(0);

// Spring to a target. Both animators return { stop } so you can cancel.
const run = progress.spring(100, { stiffness: 120, damping: 14 });
run.stop();

// Or tween with timing
progress.timing(100, { duration: 500 });

// Read the current value
progress.value();  // reactive

// Jump straight to a value with no animation
progress.setValue(42);

// Subscribe directly to the value
progress.subscribe((v) => {});

// Interpolate into a different range
const opacity = progress.interpolate(
  [0, 50, 100],    // input range
  [0, 0.5, 1]      // output range
);

// Use in JSX
<div style={() => `opacity: ${opacity()}`} />

CSS Transitions

For CSS-class-based animations, createTransitionClasses() generates the enter/exit class names used by CSS animation libraries, so the naming stays consistent between your JavaScript and your stylesheet.

import { createTransitionClasses } from 'what-framework';

// Generate class names for a transition called "fade"
const classes = createTransitionClasses('fade');
// {
//   enter: 'fade-enter',
//   enterActive: 'fade-enter-active',
//   enterDone: 'fade-enter-done',
//   exit: 'fade-exit',
//   exitActive: 'fade-exit-active',
//   exitDone: 'fade-exit-done',
// }

cssTransition() drives those classes for you and returns a promise. It adds the start class, reads a layout property on the next frame so the browser commits that starting style, adds the active class to let the CSS transition run, then swaps both for the done class and resolves:

import { cssTransition, createTransitionClasses } from 'what-framework';

const classes = createTransitionClasses('fade');

// cssTransition(element, name, type, duration)
// type defaults to 'enter', duration defaults to 300ms
await cssTransition(el, 'fade', 'enter', 300);
// el.className is now 'fade-enter-done'

// Only the start and active classes are cleaned up, so drop the done
// class yourself before running the opposite direction. Skip this and
// you end up with 'fade-enter-done fade-exit-done' on the element.
el.classList.remove(classes.enterDone);

await cssTransition(el, 'fade', 'exit', 300);
// el.className is now 'fade-exit-done'

duration is a timer, not a listener: it is how long the active class stays on before the done class replaces it, so it has to match the transition duration in your CSS or the animation is cut short. The first class lands on the next animation frame rather than synchronously, so read the element's classList after awaiting, not straight after the call. The promise resolves with undefined, and there is no cancel handle.

cssTransition does not settle on 0.13.4

This section describes the next release. On 0.13.4, the current version on npm, cssTransition() hangs: the promise never resolves and the element is left on its start class (fade-enter), so an await on it never returns. The cause is the frame scheduler rather than this function. cssTransition asks for a layout read from inside a write, and the read phase of that frame has already been drained, so the request lands in an already-drained queue with no frame armed and waits there until unrelated code happens to poke the scheduler. Nothing else on this page is affected: spring() and tween() drive their own requestAnimationFrame loops and never touch the scheduler.

Define matching CSS classes in your stylesheet:

/* CSS for fade transition */
.fade-enter { opacity: 0; }
.fade-enter-active { opacity: 1; transition: opacity 300ms; }
.fade-enter-done { opacity: 1; }
.fade-exit { opacity: 1; }
.fade-exit-active { opacity: 0; transition: opacity 300ms; }
.fade-exit-done { opacity: 0; }

Live Demo

Click the buttons below to see a physics-based spring animation in action. The black square animates to the target position with natural overshoot and settle.

Live Demo: Spring Animation