API

nsh/animated

The animation API animates on the UI thread. You create an animated 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 the app’s code. A busy engine or JS thread cannot make the animation stutter, and an animation costs the app no work per frame. Rust code uses animated_value(), Animation, Spring, Timing and Easing from nsh_core::ui::prelude and binds values with the .bind_* methods of elements; TSX imports nsh/animated.

mod-animated.tsx
mod-animated.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Animated, createAnimatedValue, Easing } from "nsh/animated";
import { desktop, Panel, Split, ui, wide } from "./palette";

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}
      activeStyle={{ opacity: 0.6 }}
      style={
        desktop
          ? { alignSelf: "flex-start", paddingHorizontal: 12, paddingVertical: 7, borderRadius: 8, overflow: "hidden", ...ui().raised, color: ui().text, fontSize: 13, fontWeight: "600" }
          : {
              paddingHorizontal: 14,
              paddingVertical: 8,
              borderRadius: 10,
              overflow: "hidden",
              ...ui().raised,
              color: ui().text,
              fontWeight: "600",
            }
      }
    >
      {props.label}
    </Text>
  );
}

function App() {
  const dots = curves.map(() => createAnimatedValue(0));
  const spin = createAnimatedValue(0);
  const status = signal(desktop ? "Click a button" : "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: ui().page, padding: 20, paddingTop: 24, gap: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Native animation</Text>
      <Split
        gap={16}
        width={360}
        inspector={
          wide() ? (
            <>
              <Panel title="Animated.parallel · six easings">
                <View style={{ gap: 10 }}>
                  <Text style={{ fontSize: 13, color: ui().text }}>{status()}</Text>
                  <Button label="Race" onPress={race} />
                </View>
              </Panel>
              <Panel title="timing · interpolate">
                <View style={{ paddingTop: 6 }}>
                  <Button label="Spin" onPress={turn} />
                </View>
              </Panel>
            </>
          ) : (
            <>
              <View style={{ flexDirection: "row", gap: 10 }}>
                <Button label="Race" onPress={race} />
                <Button label="Spin" onPress={turn} />
              </View>
              <Text style={{ color: ui().muted }}>{status()}</Text>
            </>
          )
        }
      >
      <View style={{ gap: 8 }}>
        <Each of={curves}>
          {(c, i) => (
            <View style={{ gap: 2 }}>
              <Text style={{ fontSize: 12, color: ui().muted }}>{c()[0]}</Text>
              <View style={{ width: TRACK + 16, height: 16, borderRadius: 8, backgroundColor: ui().inset.backgroundColor, boxShadow: ui().inset.boxShadow }}>
                <View
                  style={{
                    width: 16,
                    height: 16,
                    borderRadius: 8,
                    ...ui().accentFill,
                    borderWidth: 0,
                    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: [ui().up, ui().warn, ui().up] }),
            transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
          }}
        />
        <Text style={{ flex: 1, color: ui().muted, fontSize: 13 }}>
          Rotation and color are interpolated from one animated value.
        </Text>
      </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_core::ui::{LayoutAnimation, prelude::*}; // animated_value, Animation, Spring, Timing, Easing, animate_layout

See Animation for the guide.

#What an animated value can drive

WhereRustTSX
opacity.bind_opacity(&value)style={{ opacity: value }}
backgroundColor.bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()]))style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
borderColor, color.bind_border_color(&node), .bind_color(&node)The same: a node of colors. borderColor colors every side; a Text’s color colors the whole text
shadowColor, shadowOpacity, shadowRadius.bind_shadow_color(&node), .bind_shadow_opacity(&value), .bind_shadow_radius(&value)style={{ shadowOpacity: value }} (a node of colors for shadowColor)
transform.bind_translate_x(&value), .bind_translate_y, .bind_scale, .bind_rotate (degrees)translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, rotateZ steps: transform: [{ translateX: value }]
Scroll position.scroll_value(&value) on a scroll view or a list<Scroll scrollValue={value}> and <List scrollValue={value}> write the scroll offset into it each frame
Gestures.pan_values(Some(&x), Some(&y)), .pinch_value(&scale), .rotation_value(&angle)pan={{ x, y }}, pinch={{ scale }}, rotation={{ angle }} on any element: the gesture writes the values directly (see Touch and gestures)
Canvasan animated argument of a painter’s callAny 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.

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

Creates an animated value starting at value (default 0). Rust: animated_value(value), a Copy handle kept by the component that made it.

use nsh_core::ui::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let right = stored(false);
    let toggle = move || {
        let to_right = !right.get();
        right.replace(to_right);
        let _ = x.spring(if to_right { 200.0 } else { 0.0 }, Spring::default());
    };
    view()
        .flex_1()
        .p_6()
        .pt_12()
        .gap_4()
        .child(view().size(px(60.0)).rounded(px(12.0)).bg(rgb(0x6366f1)).bind_translate_x(&x))
        .child(text("Move").on_press(toggle))
}

#AnimatedValue

class AnimatedValue extends AnimatedNode {
  constructor(value: number);
  set(value: number): void;
  stop(): Promise<number>;
  getValue(): Promise<number>;
  setOffset(offset: number): void;
  flattenOffset(): void;
  extractOffset(): 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; clamp?: [number, number]; bounce?: number }): Promise<boolean>;
}
MethodRustDescription
set(value)set(value)Jumps to value, stopping any running animation.
stop()stop(), then value().awaitStops the running animation where it is; resolves with the value there.
getValue()value().awaitResolves with the current value (with its offset), read on the UI thread.
setOffset(offset)set_offset(offset)An offset added to the value: bindings see value + offset; set, animations and drags move the value.
flattenOffset()flatten_offset()Adds the offset to the value and makes it 0.
extractOffset()extract_offset()Moves the value into the offset and makes the value 0 (a drag then starts from where the last one ended).
timing(to, options)timing(to, ms, easing), timing_with(to, Timing { … })Animates to to along an easing curve. duration in ms (default 300), easing (default "easeInOut"), delay in ms (default 0).
spring(to, options)spring(to, Spring { … })Springs to to. stiffness (default 170), damping (default 26), mass (default 1), velocity (initial velocity, units per second).
decay(options)decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce)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. With clamp: [min, max] it stops at a bound, or bounces back from it keeping bounce (0..1, default 0) of its speed.

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). In Rust it returns an AnimationEnd, which a task awaits for the same bool.

let opacity = animated_value(1.0);
spawn(async move {
    if opacity.timing(0.0, 200.0, Easing::EaseInOut).await {
        visible.set(false);
    }
});

#AnimatedNode

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

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

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

MethodRustDescription
interpolate(config)interpolate(&input, &output), interpolate_colors(&input, &colors), interpolate_with(&input, output, Interpolation { … })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. Rust takes numbers, theme colors (which follow the appearance) and angles as degrees for bind_rotate.
diffClamp(min, max)diff_clamp(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, divadd, sub, mul, divArithmetic with another node or a constant.
mod(b)modulo(b)Modulo, never negative for a positive b (keeping angles in 0..360).
let scroll_y = animated_value(0.0);
let header = scroll_y.diff_clamp(0.0, 44.0);

view()
    .flex_1()
    .child(scroll().scroll_value(&scroll_y).flex_1() /* rows */)
    .child(view().h(px(44.0)).bind_translate_y(&header.mul(-1.0)))

#Animated

const Animated: {
  timing(value: AnimatedValue, config: { toValue: number; duration?: number; easing?: Easing; delay?: number }): CompositeAnimation;
  spring(value: AnimatedValue, config: { toValue: number; stiffness?: number; damping?: number; mass?: number; velocity?: number }): CompositeAnimation;
  decay(value: AnimatedValue, config?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: 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: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  subtract(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  multiply(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  divide(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  modulo(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  diffClamp(a: AnimatedNode, min: number, max: number): AnimatedNode;
  sampleSpring(config: SpringConfig & { from?: number }, fps?: number): number[];
  sampleDecay(config: DecayConfig & { from?: number }, fps?: number): number[];
};

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 round trip to the app’s code between steps). Rust builds the same tree with Animation.

FunctionRustDescription
timing, spring, decayAnimation::timing(&value, to, Timing), Animation::spring(&value, to, Spring), Animation::decay(&value, velocity, deceleration)One animation of one value; same options and defaults as the AnimatedValue methods, with the target as toValue.
sequence(list)Animation::sequence(list)Runs the animations one after another.
parallel(list, { stopTogether })Animation::parallel(list).stop_together(…)Runs them at the same time. With stopTogether (default true), stopping one stops all.
stagger(ms, list)Animation::stagger(ms, list)Runs them in parallel, each starting ms after the previous one.
delay(ms)Animation::delay(ms)Waits; useful inside sequence.
loop(animation, { iterations, resetBeforeIteration })animation.looped(iterations).reset_before_iteration(…)Repeats animation iterations times (forever by default), putting its values back before each run unless resetBeforeIteration is false.
add, subtract, multiply, divide, modulo, diffClampthe node methodsThe same as the AnimatedNode methods, as functions.
sampleSpring, sampleDecaynsh_core::ui::sample_spring, sample_decayA spring’s or decay’s values one per frame (see Easing).

CompositeAnimation:

MethodRust (Animation)Description
start(callback?)start(), an AnimationEnd to awaitRuns it (again). Resolves, and calls callback, once for the whole tree with whether it finished rather than being stopped.
stop()stop()Stops it where it is.
reset()reset()Stops it and puts its values back where they were when it started.
let ys: Vec<AnimatedValue> = (0..4).map(|_| animated_value(0.0)).collect();
let hop = Animation::stagger(
    80.0,
    ys.iter().map(|y| {
        Animation::sequence([
            Animation::timing(y, -36.0, Timing::new(220.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
            Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
        ])
    }),
);
let run = hop.start();
spawn(async move { log::info!("{}", if run.await { "done" } else { "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;
  sample(easing: Easing, count: number): number[];
  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 function of the app’s: the runtime evaluates it natively. In Rust it is the enum Easing: Linear, EaseIn, EaseOut, EaseInOut, Bezier(x1, y1, x2, y2) and Curve(curve, mode), with a Curve (Quad, Cubic, Poly(n), Sin, Circle, Exp, Elastic(bounciness), Back(s), Bounce) and an EaseMode (In, Out, InOut). To draw one, Easing.sample(easing, count) (Rust: nsh_core::ui::sample_easing) gives its values at count evenly spaced points from 0 to 1; Animated.sampleSpring and Animated.sampleDecay (sample_spring, sample_decay) give a spring’s or decay’s values one per frame (at fps, 60 by default) from from (0) until it ends. All three come from the native code the animations run. Wherever an Easing is accepted in TSX you can pass a name ("easeOut"), a cubic Bézier as four numbers, or a curve from the Easing object.

CurveRustShape
quadCurve::Quadt²
cubicCurve::Cubict³
poly(n)Curve::Poly(n)tⁿ (n defaults to 2)
sinCurve::SinSinusoidal
circleCurve::CircleCircular
expCurve::ExpExponential
elastic(bounciness)Curve::Elastic(bounciness)Springy overshoot (bounciness defaults to 1)
back(s)Curve::Back(s)Pulls back before going; s is the overshoot (default 1.70158)
bounceCurve::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; in Rust that is the EaseMode of Easing::Curve.

let _ = value.timing(1.0, 600.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
let _ = value.timing_with(1.0, Timing { easing: Easing::Bezier(0.2, 0.0, 0.0, 1.0), ..Timing::default() });
let _ = value.timing_with(1.0, Timing { easing: Easing::EaseOut, ..Timing::default() });

#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?: LayoutAnimationAnimation;    // views inserted
  update?: LayoutAnimationAnimation;    // views moved or resized
  delete?: LayoutAnimationAnimation;    // views removed
}

interface LayoutAnimationAnimation {
  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. Rust: animate_layout(LayoutAnimation), with nsh_core::ui::LayoutAnimation’s presets or its create, update and delete transitions.

MemberRustDescription
configureNext(config, onEnd?)animate_layout(config)Animates the next commit’s changes; onEnd runs when done.
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, duration))A config using the same curve for all three phases.
Presets.easeInEaseOutLayoutAnimation::EASE_IN_EASE_OUT300 ms, views fading in and out.
Presets.linearLayoutAnimation::LINEAR500 ms at constant speed.
Presets.springLayoutAnimation::SPRING700 ms; frames spring (damping 0.4), views fade linearly.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)animate_layout(LayoutAnimation::…)Shorthands for configureNext(Presets.…).
Types, PropertiesLayoutCurve, LayoutPropertyThe names, as constants.
let open = signal(false);
let toggle = move || {
    animate_layout(LayoutAnimation::SPRING);
    open.update(|o| *o = !*o);
};

view()
    .child(text("Details").on_press(toggle))
    .child(when(move || open.get(), || text("Shown with a spring.")))