Docs

Animation

Every animation in NSH runs on the UI thread. You describe it once, from Rust or TSX, and the platform plays it frame by frame (Core Animation and a display link on iOS, the UI-thread animator on Android) without calling back into your code. A busy engine thread cannot make an animation stutter, and an animation costs none of your code while it runs.

guide-animation.tsx
guide-animation.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Animated, createAnimatedValue, Easing, LayoutAnimation } from "nsh/animated";
import { desktop, Inspector, Panel, size, Stage, ui, wide } from "./palette";

const TRACK = 250;
// Read when they render, so they follow the theme.
const palette = () => [ui().down, ui().warn, ui().accent, ui().up, ui().info];
const SWATCHES = 5;

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <View
      onPress={props.onPress}
      activeStyle={{ opacity: 0.6 }}
      style={
        desktop
          ? { ...ui().raised, alignSelf: "flex-start", height: size.control, justifyContent: "center", paddingHorizontal: 12, borderRadius: 8 }
          : { ...ui().raised, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 10 }
      }
    >
      <Text style={{ color: ui().text, fontWeight: "600", fontSize: desktop ? 13 : undefined }}>{props.label}</Text>
    </View>
  );
}

function App() {
  // 1. A style transition: change the style, the platform animates it.
  const on = signal(false);

  // 2. Animated values: timing and spring, driven on the UI thread.
  const timed = createAnimatedValue(0);
  const sprung = createAnimatedValue(0);
  let there = false;
  const run = () => {
    there = !there;
    timed.timing(there ? TRACK : 0, { duration: 700, easing: Easing.inOut(Easing.cubic) });
    sprung.spring(there ? TRACK : 0, { stiffness: 180, damping: 9 });
  };

  // 3. A composite: a staggered hop, then all of them spin; runs natively end to end.
  const ys = Array.from({ length: SWATCHES }, () => createAnimatedValue(0));
  const spin = createAnimatedValue(0);
  const status = signal("idle");
  const show = Animated.sequence([
    Animated.stagger(
      70,
      ys.map((y) =>
        Animated.sequence([
          Animated.timing(y, { toValue: -30, duration: 200, easing: Easing.out(Easing.quad) }),
          Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
        ]),
      ),
    ),
    Animated.timing(spin, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.cubic) }),
  ]);
  const play = () => {
    status.set("running…");
    show.reset();
    show.start(({ finished }) => status.set(finished ? "finished" : "stopped"));
  };

  // 4. Layout animation: the next commit's frame changes animate.
  const stacked = signal(false);

  const heading = (title: string) => <Text style={{ fontWeight: "600", color: ui().text }}>{title}</Text>;
  const transition = () => (
      <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
        <View
          onPress={() => on.set(!on())}
          style={{
            width: 64,
            height: 64,
            borderRadius: on() ? 32 : 12,
            backgroundColor: on() ? ui().info : ui().accent,
            opacity: on() ? 0.6 : 1,
            transform: [{ rotate: on() ? "45deg" : "0deg" }],
            transitionProperty: "all",
            transitionDuration: 400,
            transitionTimingFunction: "ease-in-out",
          }}
        />
        <View class="size-10 rounded-full border-4 animate-spin" style={{ borderColor: ui().border, borderTopColor: ui().accent }} />
        <View class="size-10 rounded-full animate-pulse" style={{ backgroundColor: ui().up }} />
        <Text style={{ flex: 1, color: ui().muted, fontSize: 13 }}>
          {desktop ? "Click the square." : "Tap the square."} animate-spin / animate-pulse run natively.
        </Text>
      </View>
  );
  const tracks = () => (
      <>
        <View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: ui().accent, transform: [{ translateX: timed }] }} />
        <View style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: ui().info, transform: [{ translateX: sprung }] }} />
        <View
          style={{
            height: 8,
            borderRadius: 4,
            // One value, interpolated into a color and a width fraction.
            backgroundColor: timed.interpolate({ inputRange: [0, TRACK], outputRange: [ui().border, ui().up] }),
            transform: [{ scaleX: timed.interpolate({ inputRange: [0, TRACK], outputRange: [0.1, 1] }) }],
            transformOrigin: "left",
            width: wide() ? TRACK + 20 : undefined,
          }}
        />
      </>
  );
  const move = () => <Chip label="Move" onPress={run} />;
  const swatches = () => (
      <View style={{ flexDirection: "row", justifyContent: "center", alignItems: "flex-end", gap: 10, height: 64 }}>
        <Each of={palette()}>
          {(color, i) => (
            <View
              style={{
                width: 32,
                height: 32,
                borderRadius: 8,
                backgroundColor: color(),
                transform: [
                  { translateY: ys[i()] },
                  { rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "180deg"] }) },
                ],
              }}
            />
          )}
        </Each>
      </View>
  );
  const playback = () => (
      <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
        <Chip label="Play" onPress={play} />
        <Chip label="Stop" onPress={() => show.stop()} />
        <Text style={{ color: ui().muted, fontSize: desktop ? 13 : undefined }}>{status()}</Text>
      </View>
  );
  const bars = () => (
      <View style={{ flexDirection: stacked() ? "column" : "row", gap: 8 }}>
        <Each of={palette()}>
          {(color) => <View style={{ height: stacked() ? 18 : 40, flex: stacked() ? undefined : 1, borderRadius: 6, backgroundColor: color() }} />}
        </Each>
      </View>
  );
  const stack = () => (
      <Chip
        label={stacked() ? "Back to a row" : "Stack them"}
        onPress={() => {
          LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
          stacked.set(!stacked());
        }}
      />
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24, gap: 14 }}>
      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage width={520} gap={14}>
              {heading("Transition (style changes animate)")}
              {transition()}
              {heading("timing and spring")}
              <View style={{ gap: 8 }}>{tracks()}</View>
              {heading("Animated.sequence / stagger")}
              {swatches()}
              {heading("LayoutAnimation")}
              {bars()}
            </Stage>
            <Inspector>
              <Panel title="timing · spring">
                <View style={{ paddingTop: 6 }}>{move()}</View>
              </Panel>
              <Panel title="Animated.sequence · stagger">
                <View style={{ paddingTop: 6 }}>{playback()}</View>
              </Panel>
              <Panel title="LayoutAnimation.configureNext">
                <View style={{ paddingTop: 6 }}>{stack()}</View>
              </Panel>
            </Inspector>
          </View>
        ) : (
          <>
            {heading("Transition (style changes animate)")}
            {transition()}
            {heading("timing and spring")}
            <View style={{ gap: 8 }}>
              {tracks()}
              {move()}
            </View>
            {heading("Animated.sequence / stagger")}
            {swatches()}
            {playback()}
            {heading("LayoutAnimation")}
            {bars()}
            {stack()}
          </>
        )
      }
    </View>
  );
}

mount(App);

There are four tools, from the simplest to the most flexible:

ToolUse it for
TransitionsA style change that should animate instead of jump: a color, an opacity, a transform.
Keyframe animationsA spinner, a pulsing placeholder, a bouncing hint.
Animated valuesAnything driven by a value over time: springs, gestures, scroll position, sequences.
Layout animationsSize and position changes after an update: items inserted, removed, reordered, a panel expanding.

#Transitions

Name what to animate and for how long (.transition_all().duration(400.0) in Rust, transitionProperty and transitionDuration in TSX), and later changes to those properties animate natively from what is on screen to the new value.

#[component]
pub fn App() -> impl IntoElement {
    let on = signal(false);
    view().flex_1().items_center().justify_center().child(
        view()
            .on_press(move || on.update(|on| *on = !*on))
            .size(px(100.0))
            .rounded(move || px(if on.get() { 50.0 } else { 16.0 }))
            .bg(move || if on.get() { rgb(0x8250df) } else { rgb(0x0969da) })
            .rotate(move || deg(if on.get() { 45.0 } else { 0.0 }))
            .scale(move || if on.get() { 1.2_f32 } else { 1.0 })
            .transition_all()
            .duration(400.0)
            .ease_in_out(),
    )
}
StyleRustTypeDescription
transitionProperty.transition(), .transition_all(), .transition_colors(), .transition_opacity(), .transition_transform(), .transition_shadow()stringWhat animates, comma-separated: "all", "colors", "opacity", "transform", "box-shadow", "border-radius", …
transitionDuration.duration(ms)number | "150ms" | "0.2s"Milliseconds as a number, or a CSS time.
transitionDelay.delay(ms)number | "150ms" | "0.2s"Before it starts.
transitionTimingFunction.ease_linear(), .ease_in(), .ease_out(), .ease_in_out(), .ease_bezier(..)"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | "cubic-bezier(…)"The curve.

Properties that transition, by group: colors (background, border, outline, shadow and text colors, gradient stops), opacity, transform, shadows (box-shadow) and shape (border radius and width). CSS names and their camel-case forms both work (background-color, backgroundColor, transform, scale, …). Layout changes animate with layout animations.

With Tailwind, the same thing is transition (colors, opacity, shadows and transforms), transition-all, transition-colors, transition-opacity, transition-transform, transition-shadow, with duration-*, delay-* and ease-*. Combined with the pressed state (.active(..), active:) it gives pressed states that animate:

view()
    .on_press(save)
    .rounded_xl()
    .bg(BLUE.shade(600))
    .px_5()
    .py_3()
    .transition()
    .duration(150.0)
    .active(|s| s.scale(0.95).bg(BLUE.shade(800)))
    .child(text("Save").font_weight(FontWeight::SEMIBOLD).text_color(white()))

A transition starts from the value currently on screen, so changing the target again mid-way turns smoothly.

#Keyframe animations

Four built-in keyframe animations run natively, forever by default: spin (one turn clockwise), ping (scales up to 2× and fades out), pulse (fades to half opacity and back) and bounce (jumps up by a quarter of its height and lands).

view()
    .size_8()
    .rounded_full()
    .border(px(4.0))
    .border_color(ZINC.shade(300))
    .border_t_color(BLUE.shade(600))
    .animate_spin()
view().h_4().w_40().rounded(px(4.0)).bg(ZINC.shade(200)).animate_pulse()
StyleRustTypeDescription
animationName.animate_spin(), .animate_ping(), .animate_pulse(), .animate_bounce(), .animate_none(), or .animation(KeyframeAnimation::new(Keyframes::Spin))"none" | "spin" | "ping" | "pulse" | "bounce"The animation.
animationDurationKeyframeAnimation::duration(ms)number | "1s" | …One iteration.
animationDelayKeyframeAnimation::delay(ms)number | "200ms" | …Before it starts.
animationTimingFunctionKeyframeAnimation::easing([x1, y1, x2, y2])as transitionTimingFunctionThe curve.
animationIterationCountKeyframeAnimation::iterations(n)number | "infinite"Default "infinite".

Tailwind: animate-spin, animate-ping, animate-pulse, animate-bounce, animate-none.

#Animated values

An animated value is a number that lives on the UI thread. Create one (animated_value(..) in Rust, createAnimatedValue from nsh/animated in TSX), bind it to styles, and animate it; the bound views follow it every frame without an update.

#[component]
pub fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let opacity = animated_value(1.0);
    let play = move || {
        spawn(async move {
            x.timing(240.0, 600.0, Easing::Curve(Curve::Cubic, EaseMode::Out)).await;
            opacity.timing(0.3, 200.0, Easing::EaseInOut).await;
            x.spring(0.0, Spring::default());
            opacity.timing_with(1.0, Timing::default());
        });
    };
    view()
        .flex_1()
        .justify_center()
        .p_6()
        .gap_6()
        .child(
            view()
                .size(px(60.0))
                .rounded(px(12.0))
                .bg(rgb(0x0969da))
                .bind_opacity(&opacity)
                .bind_translate_x(&x),
        )
        .child(view().on_press(play).child(text("Play").text_color(rgb(0x0969da))))
}

What can be bound: opacity (.bind_opacity(..)); the background color, border color (every side) and a text’s color (the whole text), through an interpolation to colors (.bind_background(..), .bind_border_color(..), .bind_color(..)); the shadow’s color, opacity and radius (.bind_shadow_color(..), …); and the transform steps translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY and rotateZ (.bind_translate_x(..), .bind_translate_y(..), .bind_scale(..), .bind_rotate(..) in degrees, .bind_transform(kind, ..)). A focus ring or a price that flashes green is a color interpolation bound to the border or text color:

let flash = animated_value(0.0);
let color = flash.interpolate_colors(&[0.0, 1.0], &[rgb(0x1f2328), rgb(0x1a7f37)]);

text(move || price.get().to_string()).bind_color(&color)
// On a new price: flash.set(1.0); flash.timing(0.0, 600.0, Easing::EaseInOut);

Animated values also drive canvas drawings, receive scroll offsets (scrollValue) and gestures (pan, pinch, rotation).

The value is kept alive by the views bound to it and by the component that made it, so a binding stays connected however your variables come and go. Create animated values in the component that owns them (not at module level when every instance needs its own).

#AnimatedValue methods

TSXRustDescription
set(value)set(value)Jumps to value, stopping any animation.
stop()stop()Stops the running animation where it is; in TSX, resolves with the value there.
getValue()value()The current value, read on the UI thread where it moves (it settles within a frame): a promise in TSX, an awaitable answer in Rust.
setOffset(offset)set_offset(offset)Adds offset to what the value shows: bindings see value + offset, while set, animations and drags move the value.
flattenOffset()flatten_offset()Adds the offset to the value and makes the offset 0.
extractOffset()extract_offset()Moves the value into the offset and makes the value 0: a drag then starts from 0, on top of where the last one left it.
timing(to, { duration?, easing?, delay? })timing(to, ms, easing), timing_with(to, Timing { .. })Animates to to over duration ms (default 300, easing "easeInOut").
spring(to, { stiffness?, damping?, mass?, velocity? })spring(to, Spring { .. })Springs to to (defaults: stiffness 170, damping 26, mass 1).
decay({ velocity?, deceleration?, clamp?, bounce? })decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce)Coasts from velocity (units per second) and slows down (deceleration 0.998 per ms by default). With a clamp of [min, max] it stops at a bound, or bounces back from it keeping bounce (0..1) of its speed.

Each animation gives an answer you can await: true when it finished, false when something stopped it (stop(), set(), or a new animation on the same value). It is a Promise<boolean> in TSX and an AnimationEnd in Rust. Starting a new animation on a moving value interrupts the old one smoothly: a spring or decay without a velocity continues with the value’s current velocity.

// Spring settings: lower damping overshoots more, higher stiffness is faster.
x.spring(200.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() });   // wobbly
x.spring(200.0, Spring { stiffness: 400.0, damping: 30.0, ..Spring::default() });  // stiff, no overshoot

// A fling that coasts to a stop, from a pan's release velocity.
view().pan_values(Some(&x), None).on_pan_end(move |e: &PanEvent| {
    x.decay(Some(e.velocity_x), 0.998);
})

// A fling that stays on its track and bounces softly off its ends.
x.decay_within(Some(e.velocity_x), 0.998, (0.0, 300.0), 0.3);

// Where a value is now (it moves on the UI thread, so the answer is async).
let at = x.value().await;

#Interpolation

interpolate maps a value through piecewise linear segments to numbers, colors or angles, and returns a new node you can bind. One value can drive many properties. In Rust, interpolate(&input, &output) maps to numbers, interpolate_colors(&input, &colors) to colors, and interpolate_with(&input, output, Interpolation { .. }) takes the extrapolation and easing; in TSX, value.interpolate({ inputRange, outputRange }) takes them all.

let t = animated_value(0.0);

view()
    .bind_background(&t.interpolate_colors(&[0.0, 1.0, 2.0], &[rgb(0x54aeff), rgb(0x8250df), rgb(0xcf222e)]))
    .bind_opacity(&t.interpolate(&[0.0, 1.0], &[0.4, 1.0]))
    .bind_rotate(&t.interpolate(&[0.0, 1.0], &[0.0, 180.0]))
    .bind_scale(&t.interpolate_with(
        &[0.0, 1.0],
        AnimationOutput::Numbers(vec![0.6, 1.2]),
        Interpolation {
            easing: Easing::Curve(Curve::Back(1.70158), EaseMode::Out),
            ..Interpolation::default()
        },
    ))
OptionRustDescription
inputRangethe input sliceIncreasing input stops.
outputRangethe output slice, or AnimationOutputOne output per stop: numbers, colors ("#rrggbb", "rgba(…)", named) or angles ("45deg", "1rad").
extrapolateInterpolation::extrapolate(Extrapolate::Extend)Outside the input range: "clamp" (default), "extend" (continue the end segments) or "identity" (pass the input through).
extrapolateLeft, extrapolateRightextrapolate_left, extrapolate_rightThe same, for one side.
easingeasingShapes each segment.

#Arithmetic and diffClamp

Nodes combine with arithmetic, also natively: a.add(b), a.sub(b), a.mul(b), a.div(b), a.mod(b) (a.modulo(b) in Rust; never negative for a positive b, e.g. angles in 0..360), where b is a node or a number. In TSX, Animated.add, subtract, multiply, divide and modulo are the same as functions.

diffClamp(min, max) (diff_clamp) follows the node’s changes while staying within min..max. Applied to a scroll offset, it makes a header that hides as the content scrolls up and comes back as soon as it scrolls down:

let offset = animated_value(0.0);
let header = offset.diff_clamp(0.0, 56.0);

scroll().flex_1().scroll_value(&offset)
view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&header.mul(-1.0))

#Composite animations

Animations are built out of steps (Animation in Rust, Animated from nsh/animated in TSX) and the whole tree runs natively: each step starts on the UI thread the moment the previous one ends, with no gap and none of your code in between.

let ys: Vec<AnimatedValue> = (0..5).map(|_| animated_value(0.0)).collect();
let fade = animated_value(1.0);

let hop = Animation::stagger(
    80.0,
    ys.iter().map(|y| {
        Animation::sequence([
            Animation::timing(y, -30.0, Timing::new(200.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
            Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
        ])
    }),
);

let breathe = Animation::sequence([
    Animation::timing(&fade, 0.3, Timing::new(400.0, Easing::EaseInOut)),
    Animation::timing(&fade, 1.0, Timing::new(400.0, Easing::EaseInOut)),
])
.looped(None);

let landed = hop.start();
spawn(async move {
    status.set(if landed.await { "landed" } else { "stopped" });
});
TSXRustDescription
Animated.timing(value, { toValue, duration?, easing?, delay? })Animation::timing(&value, to, Timing { .. })A timing step.
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? })Animation::spring(&value, to, Spring { .. })A spring step.
Animated.decay(value, { velocity?, deceleration?, clamp?, bounce? })Animation::decay(&value, velocity, deceleration), Animation::decay_within(..)A decay step.
Animated.sequence(list)Animation::sequence(list)One after another.
Animated.parallel(list, { stopTogether? })Animation::parallel(list), .stop_together(..)All at once.
Animated.stagger(ms, list)Animation::stagger(ms, list)All at once, each starting ms after the previous one.
Animated.delay(ms)Animation::delay(ms)Waits.
Animated.loop(animation, { iterations?, resetBeforeIteration? }).looped(iterations), .reset_before_iteration(..)Repeats (forever by default; None in Rust).

A composite animation has start() (in TSX also with a callback and a promise of finished; in Rust an awaitable bool; calling it again runs it again), stop() (stops where it is) and reset() (stops and puts its values back where they were when it started). The callback runs once for the whole tree.

A running animation does not keep its values alive: when the component that owns them goes (its views and variables), an endless loop stops with them, and its answer is false.

#Easing

Easing curves are data, evaluated natively: Easing from nsh/animated in TSX, the Easing enum in Rust.

TSXRustDescription
Easing.linear, Easing.easeEasing::Linear, Easing::Bezier(0.25, 0.1, 0.25, 1.0)Constant speed; CSS’s ease.
Easing.quad, Easing.cubic, Easing.poly(n)Curve::Quad, Curve::Cubic, Curve::Poly(n)t², t³, tⁿ.
Easing.sin, Easing.circle, Easing.expCurve::Sin, Curve::Circle, Curve::ExpSinusoidal, circular, exponential.
Easing.back(s?)Curve::Back(s)Pulls back before going (overshoot s, 1.70158 by default).
Easing.elastic(bounciness?)Curve::Elastic(bounciness)Springy overshoot.
Easing.bounceCurve::BounceBounces at the end.
Easing.bezier(x1, y1, x2, y2)Easing::Bezier(x1, y1, x2, y2)A cubic Bézier, as in CSS.
Easing.in(e), Easing.out(e), Easing.inOut(e)Easing::Curve(curve, EaseMode::In), EaseMode::Out, EaseMode::InOutThe curves are “in” curves; out and inOut turn them around.

In TSX, anywhere an easing is accepted you can also pass a name ("linear", "ease", "easeIn", "easeOut", "easeInOut") or a Bézier array [x1, y1, x2, y2]; in Rust, Easing::EaseIn, Easing::EaseOut and Easing::EaseInOut are the CSS curves.

x.timing(1.0, 300.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
x.timing(1.0, 300.0, Easing::EaseOut);
x.timing(1.0, 300.0, Easing::Bezier(0.2, 0.8, 0.2, 1.0));

Easing.sample(easing, count) (sample_easing in Rust) returns the curve at count evenly spaced points from 0 to 1, and Animated.sampleSpring(config, fps?) / Animated.sampleDecay(config, fps?) (sample_spring, sample_decay) the values a spring or decay moves through, one per frame (60 per second by default) from config.from (0) until it ends. They are computed by the same native code the animations run, so a chart of a curve shows exactly what the animation does:

use nsh_core::ui::{sample_easing, sample_spring};

let curve = sample_easing(Easing::Curve(Curve::Back(1.70158), EaseMode::Out), 50); // 50 progress values
let spring = sample_spring(0.0, 1.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() }, 60.0);

#LayoutAnimation

A layout animation animates the frame changes of the next update natively: views that move or resize glide to their new frames, new views fade (or scale) in, removed views fade out. Call it right before the state change: animate_layout(..) in Rust, LayoutAnimation.configureNext(config) in TSX.

use nsh_core::ui::LayoutAnimation;

let items = signal(vec![1, 2, 3]);
let next = stored(4);

let add = move || {
    animate_layout(LayoutAnimation::SPRING);
    let n = next.with(|n| {
        *n += 1;
        *n - 1
    });
    items.update(|i| i.insert(0, n));
};
let remove = move || {
    animate_layout(LayoutAnimation::EASE_IN_EASE_OUT);
    items.update(|i| {
        i.remove(0);
    });
};
TSXRustDescription
configureNext(config, onEnd?)animate_layout(config)Animates the next commit’s changes; in TSX, onEnd runs when done.
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, ms))A config with one curve for all three phases.
Presets.easeInEaseOut, Presets.linear, Presets.springLayoutAnimation::EASE_IN_EASE_OUT, LINEAR, SPRINGReady-made configs.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)animate_layout with a presetconfigureNext with a preset.
Types, PropertiesLayoutCurve, LayoutPropertyThe names, for building configs.

A config is { duration, create?, update?, delete? } (LayoutAnimation { create, update, delete } in Rust): create for inserted views, update for moved or resized ones, delete for removed ones. Each phase is { type?, property?, duration?, delay?, springDamping? } (LayoutTransition { curve, property, duration_ms, delay_ms }), where type is "easeInEaseOut", "spring", "linear", "easeIn" or "easeOut", property is what appearing and disappearing views animate ("opacity", "scaleXY", "scale"), and springDamping (0..1, 0.7 by default; LayoutCurve::Spring { damping }) sets the spring’s overshoot.

use nsh_core::ui::{LayoutAnimation, LayoutCurve, LayoutProperty, LayoutTransition};

animate_layout(LayoutAnimation {
    create: Some(LayoutTransition::new(LayoutCurve::EaseOut, 300.0)),
    update: Some(LayoutTransition::new(LayoutCurve::Spring { damping: 0.8 }, 300.0)),
    delete: Some(LayoutTransition {
        property: LayoutProperty::Scale,
        ..LayoutTransition::new(LayoutCurve::EaseIn, 300.0)
    }),
});

#What runs where

Runs onYour code per frame
Transitions, keyframe animationsUI threadnone
timing / spring / decay, compositesUI threadnone; your code hears when they end
interpolate, arithmetic, diffClampUI threadnone
scrollValue, pan / pinch / rotationUI threadnone; your code hears start and end
Layout animationsUI threadnone
requestAnimationFrame loops (request_animation_frame in Rust), onScroll handlers, PanResponderyour code, then an updateevery frame

Reach for requestAnimationFrame (a global in TSX, following the display) only for what cannot be described ahead of time, such as a physics simulation in your code. Anything that can be an animated value should be one.

#Reduced motion

When the user asks for less motion (iOS “Reduce Motion”, Android “Remove animations”), reduce_motion() in Rust and reduceMotion() from nsh/accessibility in TSX are true, and the motion-reduce: / motion-safe: Tailwind variants apply. Your animations play as written; use these to choose a calmer alternative:

let open = move || {
    if reduce_motion() {
        panel.set(0.0);
    } else {
        panel.spring(0.0, Spring::default());
    }
};

view().style_when(|| !reduce_motion(), |s| s.animate_bounce())

See Accessibility.