@fcannizzaro/streamdeck-react

Animation Hooks

Physics-based and easing-based animation hooks for smooth value transitions.

useSpring

Spring physics-based animation hook. Returns animated value(s) that follow the target with natural spring dynamics (damped harmonic oscillator). Supports single numbers and objects of numbers.

function useSpring<T extends AnimationTarget>(
  target: T,
  config?: Partial<SpringConfig> & { fps?: number },
): SpringResult<T>;
type AnimationTarget = number | Record<string, number>;

type AnimatedValue<T extends AnimationTarget> = T extends number
  ? number
  : { [K in keyof T]: number };
interface SpringConfig {
  /** Stiffness coefficient (force per unit displacement). @default 170 */
  tension: number;
  /** Damping coefficient (force per unit velocity). @default 26 */
  friction: number;
  /** Mass of the simulated object. @default 1 */
  mass: number;
  /** Absolute velocity threshold below which the spring settles. @default 0.01 */
  velocityThreshold: number;
  /** Absolute displacement threshold below which the spring settles. @default 0.005 */
  displacementThreshold: number;
  /** Clamp output to target (no overshoot). @default false */
  clamp: boolean;
}
interface SpringResult<T extends AnimationTarget> {
  /** Current interpolated value(s). */
  value: AnimatedValue<T>;
  /** Whether the spring is still in motion. */
  isAnimating: boolean;
  /** Imperatively update the target (starts animating from current position). */
  set: (target: T) => void;
  /** Jump immediately to a value with zero velocity (no animation). */
  jump: (target: T) => void;
}

Scalar example

function SpringBounce() {
  const [pressed, setPressed] = useState(false);
  const [count, setCount] = useState(0);

  useKeyDown(() => {
    setPressed(true);
    setCount((c) => c + 1);
  });

  useKeyUp(() => setPressed(false));

  // Wobbly scale spring -- bounces on press
  const { value: scale } = useSpring(pressed ? 0.8 : 1, {
    ...SpringPresets.wobbly,
    tension: 300,
  });

  // Gentle background hue shift based on count
  const { value: hue } = useSpring((count * 40) % 360, SpringPresets.gentle);

  const size = Math.round(scale * 100);

  return (
    <div
      className={tw("flex items-center justify-center w-full h-full")}
      style={{ backgroundColor: `hsl(${Math.round(hue)}, 60%, 25%)` }}
    >
      <div
        className={tw("flex flex-col items-center justify-center")}
        style={{ width: `${size}%`, height: `${size}%` }}
      >
        <span className="text-white/50 text-[10px] font-medium">SPRING</span>
        <span className="text-white text-[48px] font-bold">{count}</span>
      </div>
    </div>
  );
}

Object example

Pass an object of named numbers to animate multiple channels independently:

const { value } = useSpring({ x: targetX, opacity: show ? 1 : 0 }, SpringPresets.gentle);
// value.x and value.opacity are plain numbers

Imperative API

Use set() to start a new animation from the current position toward a new target. Use jump() to snap immediately with zero velocity (no animation).

const { value, set, jump } = useSpring(0);

useKeyDown(() => set(100)); // animate to 100
useLongPress(() => jump(0)); // snap back instantly

Implementation notes

  • Uses semi-implicit Euler integration of a damped harmonic oscillator.
  • Internally drives re-renders via useTick -- the tick loop starts when the target changes and stops once all channels settle below the velocity and displacement thresholds.
  • The fps option (default 30) controls the tick rate. Actual frame rate is still capped by the hardware refresh rate (max 30Hz).

SpringPresets

Built-in spring configurations for common animation feels. Pass directly as the config argument or spread and override individual properties.

import { SpringPresets } from "@fcannizzaro/streamdeck-react";
PresetTensionFrictionMassClampUse case
default170261--Balanced default
stiff400281--Quick and responsive, press feedback
wobbly180121--Bouncy with visible oscillation
gentle120141--Slow and smooth, background shifts
molasses80301--Very slow, ambient drift
snap300361yesSnappy with no overshoot
heavy200203--Heavy object feel
// Use a preset directly
const { value } = useSpring(target, SpringPresets.wobbly);

// Override a single property
const { value } = useSpring(target, { ...SpringPresets.stiff, friction: 20 });

useTween

Duration and easing-based animation hook. Returns animated value(s) that smoothly transition to the target over a specified duration using an easing curve. Supports single numbers and objects of numbers.

When the target changes mid-tween, a new tween starts from the current interpolated position (no discontinuity).

function useTween<T extends AnimationTarget>(
  target: T,
  config?: Partial<TweenConfig>,
): TweenResult<T>;
interface TweenConfig {
  /** Duration in milliseconds. @default 300 */
  duration: number;
  /** Easing function name or custom (t: number) => number. @default "easeOut" */
  easing: EasingName | EasingFn;
  /** Target FPS for the animation tick loop. @default 60 */
  fps: number;
}
interface TweenResult<T extends AnimationTarget> {
  /** Current interpolated value(s). */
  value: AnimatedValue<T>;
  /** 0..1 normalized progress of the current transition. */
  progress: number;
  /** Whether the tween is still running. */
  isAnimating: boolean;
  /** Imperatively start a new tween from the current position. */
  set: (target: T) => void;
  /** Jump immediately to a value (no animation). */
  jump: (target: T) => void;
}

Scalar example

const LABELS = ["HELLO", "WORLD", "REACT", "DECK"];

function FadeSlide() {
  const [index, setIndex] = useState(0);

  useKeyDown(() => {
    setIndex((i) => (i + 1) % LABELS.length);
  });

  // Tween background hue when cycling
  const { value: bg } = useTween(index * 90, {
    duration: 400,
    easing: "easeOutCubic",
  });

  return (
    <div
      className={tw("flex flex-col items-center justify-center w-full h-full")}
      style={{ backgroundColor: `hsl(${Math.round(bg) % 360}, 50%, 25%)` }}
    >
      <span className="text-white text-[28px] font-bold">{LABELS[index]}</span>
    </div>
  );
}

Object example

const { value } = useTween(
  { y: expanded ? 0 : -50, opacity: expanded ? 1 : 0 },
  { duration: 500, easing: "easeInOut" },
);
// value.y and value.opacity are plain numbers

Custom easing function

Pass a (t: number) => number function instead of a named easing:

const { value } = useTween(target, {
  duration: 600,
  easing: (t) => 1 - Math.pow(1 - t, 4), // custom ease-out quartic
});

Implementation notes

  • Classic duration + easing interpolation. On each tick, elapsed time advances, progress is computed as elapsed / duration, the easing function is applied, and channels are linearly interpolated.
  • When the target changes mid-tween, the current interpolated position becomes the new start and a fresh tween begins -- no visual discontinuity.
  • Like useSpring, the tick loop only runs while isAnimating is true.

Easings

Built-in easing functions. Use by name in TweenConfig.easing or access the functions directly via the Easings object.

import { Easings } from "@fcannizzaro/streamdeck-react";

type EasingName =
  | "linear"
  | "easeIn"
  | "easeOut"
  | "easeInOut"
  | "easeInCubic"
  | "easeOutCubic"
  | "easeInOutCubic"
  | "easeInBack"
  | "easeOutBack"
  | "easeOutBounce";

type EasingFn = (t: number) => number;
NameDescription
linearNo easing, constant speed
easeInQuadratic ease-in (slow start)
easeOutQuadratic ease-out (slow end)
easeInOutQuadratic ease-in-out
easeInCubicCubic ease-in
easeOutCubicCubic ease-out
easeInOutCubicCubic ease-in-out
easeInBackSlight overshoot at the start
easeOutBackSlight overshoot at the end
easeOutBounceBounce effect at the end

Note on Animation Performance

Both hooks use useTick internally and accept an fps option (default 30, max 30). The actual frame rate is capped at 30fps — Stream Deck hardware refreshes at max 30Hz. In practice, rendering time limits real throughput to roughly 10-30fps depending on component complexity.

The tick loop is only active while the animation is in motion. Once the spring settles or the tween completes, useTick is paused automatically -- no CPU cost when idle.

On this page