API

nsh/animated

nsh/animated animates on the UI thread. You create a native value, bind it (or values derived from it) to a style property, and start an animation; from then on every frame is computed natively, without running JavaScript. A busy JS thread cannot make the animation stutter, and an animation costs no JS work per frame.

mod-animated.tsx Edit in Playground Open in new tab
import { mount, signal, Text, View } from "nsh";
import { Animated, createNativeValue, Easing } from "nsh/animated";

const TRACK = 240;
const curves: [string, Easing][] = [
  ["linear", Easing.linear],
  ["quad out", Easing.out(Easing.quad)],
  ["cubic inOut", Easing.inOut(Easing.cubic)],
  ["back out", Easing.out(Easing.back())],
  ["elastic out", Easing.out(Easing.elastic(1))],
  ["bounce out", Easing.out(Easing.bounce)],
];

function Button(props: { label: string; onPress: () => void }) {
  return (
    <Text
      onPress={props.onPress}
      activeOpacity={0.6}
      style={{
        paddingHorizontal: 14,
        paddingVertical: 8,
        borderRadius: 16,
        overflow: "hidden",
        backgroundColor: "#e0e7ff",
        color: "#4338ca",
        fontWeight: "600",
      }}
    >
      {props.label}
    </Text>
  );
}

function App() {
  const dots = curves.map(() => createNativeValue(0));
  const spin = createNativeValue(0);
  const status = signal("Tap a button");
  let there = false;

  const race = () => {
    there = !there;
    status.set("running…");
    Animated.parallel(
      dots.map((x, i) => Animated.timing(x, { toValue: there ? TRACK : 0, duration: 1000, easing: curves[i][1] })),
    )
      .start()
      .then((finished) => status.set(finished ? "finished" : "stopped"));
  };

  const turn = () => {
    spin.timing(0);
    spin.timing(1, { duration: 900, easing: Easing.inOut(Easing.cubic) });
  };

  return (
    <View style={{ flex: 1, backgroundColor: "#f8fafc", padding: 20, paddingTop: 24, gap: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: "700" }}>Native animation</Text>
      <View style={{ gap: 8 }}>
        <Each of={curves}>
          {(c, i) => (
            <View style={{ gap: 2 }}>
              <Text style={{ fontSize: 12, color: "#64748b" }}>{c()[0]}</Text>
              <View style={{ width: TRACK + 16, height: 16, borderRadius: 8, backgroundColor: "#e2e8f0" }}>
                <View
                  style={{
                    width: 16,
                    height: 16,
                    borderRadius: 8,
                    backgroundColor: "#6366f1",
                    transform: [{ translateX: dots[i()] }],
                  }}
                />
              </View>
            </View>
          )}
        </Each>
      </View>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <View
          style={{
            width: 64,
            height: 64,
            borderRadius: 14,
            backgroundColor: spin.interpolate({ inputRange: [0, 0.5, 1], outputRange: ["#22c55e", "#eab308", "#22c55e"] }),
            transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
          }}
        />
        <Text style={{ flex: 1, color: "#475569", fontSize: 13 }}>
          Rotation and color are interpolated from one native value.
        </Text>
      </View>
      <View style={{ flexDirection: "row", gap: 10 }}>
        <Button label="Race" onPress={race} />
        <Button label="Spin" onPress={turn} />
      </View>
      <Text style={{ color: "#64748b" }}>{status()}</Text>
    </View>
  );
}

mount(App);
import { Animated, createNativeValue, Easing, LayoutAnimation, NativeNode, NativeValue } from "nsh/animated";

See Animation for the guide.

#What a native value can drive

WhereHow
opacitystyle={{ opacity: value }}
backgroundColorstyle={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
transformtranslateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ steps: transform: [{ translateX: value }]
Scroll position<Scroll scrollValue={value}> and <List scrollValue={value}> write the scroll offset into it each frame
Gesturespan={{ x, y }}, pinch={{ scale }}, rotation={{ angle }} on any element: the gesture writes the values directly (see Touch and gestures)
CanvasAny number or color argument of a canvas drawing call

Other style properties take plain values; to animate the layout itself (sizes, positions in the flow), use LayoutAnimation.

#createNativeValue

function createNativeValue(value?: number): NativeValue;

Creates a native value starting at value (default 0).

import { mount, Text, View } from "nsh";
import { createNativeValue } from "nsh/animated";

function App() {
  const x = createNativeValue(0);
  let right = false;
  const toggle = () => {
    right = !right;
    x.spring(right ? 200 : 0);
  };
  return (
    <View style={{ flex: 1, padding: 24, paddingTop: 48, gap: 16 }}>
      <View style={{ width: 60, height: 60, borderRadius: 12, backgroundColor: "#6366f1", transform: [{ translateX: x }] }} />
      <Text onPress={toggle}>Move</Text>
    </View>
  );
}

mount(App);

#NativeValue

class NativeValue extends NativeNode {
  constructor(value: number);
  set(value: number): void;
  stop(): void;
  timing(to: number, options?: { duration?: number; easing?: Easing; delay?: number }): Promise<boolean>;
  spring(to: number, options?: { stiffness?: number; damping?: number; mass?: number; velocity?: number }): Promise<boolean>;
  decay(options?: { velocity?: number; deceleration?: number }): Promise<boolean>;
}
MethodDescription
set(value)Jumps to value, stopping any running animation.
stop()Stops the running animation where it is.
timing(to, options)Animates to to along an easing curve. duration in ms (default 300), easing (default "easeInOut"), delay in ms (default 0).
spring(to, options)Springs to to. stiffness (default 170), damping (default 26), mass (default 1), velocity (initial velocity, units per second).
decay(options)Coasts from velocity (units per second) and slows down by deceleration per millisecond (default 0.998). Use it after a fling, with the velocity from onPanEnd.

Each animation method returns a promise that resolves with true when the animation finished and false when it was stopped (by stop, set or another animation of the same value).

const opacity = createNativeValue(1);
const finished = await opacity.timing(0, { duration: 200 });
if (finished) visible.set(false);

#NativeNode

class NativeNode {
  interpolate(config: {
    inputRange: number[];
    outputRange: number[] | string[];
    extrapolate?: Extrapolation;
    extrapolateLeft?: Extrapolation;
    extrapolateRight?: Extrapolation;
    easing?: Easing;
  }): NativeNode;
  diffClamp(min: number, max: number): NativeNode;
  add(b: NativeNode | number): NativeNode;
  sub(b: NativeNode | number): NativeNode;
  mul(b: NativeNode | number): NativeNode;
  div(b: NativeNode | number): NativeNode;
  mod(b: NativeNode | number): NativeNode;
}

type Extrapolation = "extend" | "clamp" | "identity";

A node of the native animation graph: a NativeValue, or a value derived from others. Derived nodes are evaluated on the UI thread whenever their inputs change.

MethodDescription
interpolate(config)Maps the input through piecewise ranges. outputRange holds numbers, colors ("#ff0000", "rgba(…)") or angles ("45deg", "1rad"). Outside inputRange it clamps by default; extrapolate (or extrapolateLeft / extrapolateRight) can be "extend" (continue the line), "clamp" or "identity" (pass the input through). easing shapes each segment.
diffClamp(min, max)Follows the input’s changes but keeps the result within min..max: a header that hides while content scrolls up and comes back as soon as it scrolls down.
add, sub, mul, divArithmetic with another node or a constant.
mod(b)Modulo, never negative for a positive b (keeping angles in 0..360).
const scroll = createNativeValue(0);
const header = scroll.diffClamp(0, 44);

<Scroll scrollValue={scroll} style={{ flex: 1 }}>{/* rows */}</Scroll>
<View style={{ height: 44, transform: [{ translateY: header.mul(-1) }] }} />
const progress = createNativeValue(0);
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
const color = progress.interpolate({ inputRange: [0, 0.5, 1], outputRange: ["#22c55e", "#eab308", "#ef4444"] });

#Animated

const Animated: {
  timing(value: NativeValue, config: { toValue: number; duration?: number; easing?: Easing; delay?: number }): CompositeAnimation;
  spring(value: NativeValue, config: { toValue: number; stiffness?: number; damping?: number; mass?: number; velocity?: number }): CompositeAnimation;
  decay(value: NativeValue, config?: { velocity?: number; deceleration?: number }): CompositeAnimation;
  sequence(list: CompositeAnimation[]): CompositeAnimation;
  parallel(list: CompositeAnimation[], config?: { stopTogether?: boolean }): CompositeAnimation;
  stagger(ms: number, list: CompositeAnimation[]): CompositeAnimation;
  delay(ms: number): CompositeAnimation;
  loop(animation: CompositeAnimation, config?: { iterations?: number; resetBeforeIteration?: boolean }): CompositeAnimation;
  add(a: NativeNode, b: NativeNode | number): NativeNode;
  subtract(a: NativeNode, b: NativeNode | number): NativeNode;
  multiply(a: NativeNode, b: NativeNode | number): NativeNode;
  divide(a: NativeNode, b: NativeNode | number): NativeNode;
  modulo(a: NativeNode, b: NativeNode | number): NativeNode;
  diffClamp(a: NativeNode, min: number, max: number): NativeNode;
};

interface CompositeAnimation {
  start(callback?: (result: { finished: boolean }) => void): Promise<boolean>;
  stop(): void;
  reset(): void;
}

Animated builds animations out of steps (timing, spring, decay, sequence, parallel, …). Nothing runs until start(); then the whole tree runs natively, and each step starts on the UI thread at the exact moment the previous one ends (no frame gap, no JS round trip between steps).

FunctionDescription
timing, spring, decayOne animation of one value; same options and defaults as the NativeValue methods, with the target as toValue.
sequence(list)Runs the animations one after another.
parallel(list, { stopTogether })Runs them at the same time. With stopTogether (default true), stopping one stops all.
stagger(ms, list)Runs them in parallel, each starting ms after the previous one.
delay(ms)Waits; useful inside sequence.
loop(animation, { iterations, resetBeforeIteration })Repeats animation iterations times (forever by default), putting its values back before each run unless resetBeforeIteration is false.
add, subtract, multiply, divide, modulo, diffClampThe same as the NativeNode methods, as functions.

CompositeAnimation:

MethodDescription
start(callback?)Runs it (again). Resolves, and calls callback, once for the whole tree with whether it finished rather than being stopped.
stop()Stops it where it is.
reset()Stops it and puts its values back where they were when it started.
const ys = [0, 1, 2, 3].map(() => createNativeValue(0));
const hop = Animated.stagger(
  80,
  ys.map((y) =>
    Animated.sequence([
      Animated.timing(y, { toValue: -36, duration: 220, easing: Easing.out(Easing.quad) }),
      Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
    ]),
  ),
);
hop.start(({ finished }) => console.log(finished ? "done" : "stopped"));

#Easing

type Easing =
  | "linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
  | [number, number, number, number]   // cubic Bézier x1, y1, x2, y2
  | EasingCurve;

const Easing: {
  linear: Easing;
  ease: Easing;
  bezier(x1: number, y1: number, x2: number, y2: number): Easing;
  in(e: Easing): Easing;
  out(e: Easing): Easing;
  inOut(e: Easing): Easing;
  quad: EasingCurve;
  cubic: EasingCurve;
  poly(n?: number): EasingCurve;
  sin: EasingCurve;
  circle: EasingCurve;
  exp: EasingCurve;
  elastic(bounciness?: number): EasingCurve;
  back(s?: number): EasingCurve;
  bounce: EasingCurve;
};

An easing is data, not a JS function: the runtime evaluates it natively. Wherever an Easing is accepted you can pass a name ("easeOut"), a cubic Bézier as four numbers, or a curve from the Easing object.

CurveShape
quadt²
cubict³
poly(n)tⁿ (n defaults to 2)
sinSinusoidal
circleCircular
expExponential
elastic(bounciness)Springy overshoot (bounciness defaults to 1)
back(s)Pulls back before going; s is the overshoot (default 1.70158)
bounceBounces at the end

The curves are in their “in” form (slow at the start). Easing.out(curve) reverses one, Easing.inOut(curve) makes it symmetric, Easing.in(curve) keeps it as is.

value.timing(1, { duration: 600, easing: Easing.out(Easing.back()) });
value.timing(1, { easing: Easing.bezier(0.2, 0, 0, 1) });
value.timing(1, { easing: "easeOut" });

#LayoutAnimation

const LayoutAnimation: {
  configureNext(config: LayoutAnimationConfig, onEnd?: () => void): void;
  create(duration: number, type?: LayoutAnimationType, property?: LayoutAnimationProperty): LayoutAnimationConfig;
  Types: { easeInEaseOut; spring; linear; easeIn; easeOut };
  Properties: { opacity; scaleXY };
  Presets: { easeInEaseOut: LayoutAnimationConfig; linear: LayoutAnimationConfig; spring: LayoutAnimationConfig };
  easeInEaseOut(onEnd?: () => void): void;
  linear(onEnd?: () => void): void;
  spring(onEnd?: () => void): void;
};

interface LayoutAnimationConfig {
  duration: number;                // ms
  create?: LayoutAnimationAnim;    // views inserted
  update?: LayoutAnimationAnim;    // views moved or resized
  delete?: LayoutAnimationAnim;    // views removed
}

interface LayoutAnimationAnim {
  type?: "easeInEaseOut" | "spring" | "linear" | "easeIn" | "easeOut";
  property?: "opacity" | "scaleXY" | "scale";
  duration?: number;
  delay?: number;
  springDamping?: number;          // 0..1, default 0.7
}

LayoutAnimation.configureNext(config) animates the layout changes of the next commit natively: views that move or resize glide to their new frames, new views appear (fading or scaling in, by property) and removed views disappear. Call it right before the state change that changes the layout.

MemberDescription
configureNext(config, onEnd?)Animates the next commit’s changes; onEnd runs when done.
create(duration, type?, property?)A config using the same curve for all three phases.
Presets.easeInEaseOut300 ms, views fading in and out.
Presets.linear500 ms at constant speed.
Presets.spring700 ms; frames spring (damping 0.4), views fade linearly.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)Shorthands for configureNext(Presets.…).
Types, PropertiesThe names, as constants.
const open = signal(false);
const toggle = () => {
  LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
  open.update((o) => !o);
};

<View>
  <Text onPress={toggle}>Details</Text>
  <If when={open()}>
    <Text>Shown with a spring.</Text>
  </If>
</View>