Guide
Animation
Every animation in nsh runs on the UI thread. You describe it from JS, once, 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 JS. A busy JS thread cannot make an animation stutter, and an animation costs no JS while it runs.
import { mount, signal, Text, View } from "nsh";
import { Animated, createNativeValue, Easing, LayoutAnimation } from "nsh/animated";
const TRACK = 250;
const muted = "#57606a";
const palette = ["#fd8c73", "#f2cc60", "#4ac26b", "#54aeff", "#8250df"];
function Chip(props: { label: string; onPress: () => void }) {
return (
<View onPress={props.onPress} activeOpacity={0.6} style={{ paddingHorizontal: 12, paddingVertical: 7, borderRadius: 14, backgroundColor: "#ddf4ff" }}>
<Text style={{ color: "#0969da", fontWeight: "600" }}>{props.label}</Text>
</View>
);
}
function App() {
// 1. A style transition: change the style, the platform animates it.
const on = signal(false);
// 2. Native values: timing and spring, driven on the UI thread.
const timed = createNativeValue(0);
const sprung = createNativeValue(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 = palette.map(() => createNativeValue(0));
const spin = createNativeValue(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 wide = signal(false);
return (
<View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
<Text style={{ fontWeight: "600" }}>Transition (style changes animate)</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View
onPress={() => on.set(!on())}
style={{
width: 64,
height: 64,
borderRadius: on() ? 32 : 12,
backgroundColor: on() ? "#8250df" : "#0969da",
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 border-zinc-300 border-t-blue-600 animate-spin" />
<View class="size-10 rounded-full bg-emerald-500 animate-pulse" />
<Text style={{ flex: 1, color: muted, fontSize: 13 }}>Tap the square. animate-spin / animate-pulse run natively.</Text>
</View>
<Text style={{ fontWeight: "600" }}>timing and spring</Text>
<View style={{ gap: 8 }}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: "#0969da", transform: [{ translateX: timed }] }} />
<View style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: "#bf3989", 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: ["#d0d7de", "#1a7f37"] }),
transform: [{ scaleX: timed.interpolate({ inputRange: [0, TRACK], outputRange: [0.1, 1] }) }],
transformOrigin: "left",
}}
/>
<Chip label="Move" onPress={run} />
</View>
<Text style={{ fontWeight: "600" }}>Animated.sequence / stagger</Text>
<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>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Chip label="Play" onPress={play} />
<Chip label="Stop" onPress={() => show.stop()} />
<Text style={{ color: muted }}>{status()}</Text>
</View>
<Text style={{ fontWeight: "600" }}>LayoutAnimation</Text>
<View style={{ flexDirection: wide() ? "column" : "row", gap: 8 }}>
<Each of={palette}>
{(color) => <View style={{ height: wide() ? 18 : 40, flex: wide() ? undefined : 1, borderRadius: 6, backgroundColor: color() }} />}
</Each>
</View>
<Chip
label={wide() ? "Back to a row" : "Stack them"}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
wide.set(!wide());
}}
/>
</View>
);
}
mount(App);There are four tools, from the simplest to the most flexible:
| Tool | Use it for |
|---|---|
| Transitions | A style change that should animate instead of jump: a color, an opacity, a transform. |
| Keyframe animations | A spinner, a pulsing placeholder, a bouncing hint. |
| Native values | Anything driven by a value over time: springs, gestures, scroll position, sequences. |
| LayoutAnimation | Size and position changes after a re-render: items inserted, removed, reordered, a panel expanding. |
#Transitions
Give a view transitionProperty and transitionDuration, and later changes to those properties animate natively from what is on screen to the new value.
import { mount, signal, View } from "nsh";
function App() {
const on = signal(false);
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<View
onPress={() => on.set(!on())}
style={{
width: 100,
height: 100,
borderRadius: on() ? 50 : 16,
backgroundColor: on() ? "#8250df" : "#0969da",
transform: [{ rotate: on() ? "45deg" : "0deg" }, { scale: on() ? 1.2 : 1 }],
transitionProperty: "all",
transitionDuration: 400,
transitionTimingFunction: "ease-in-out",
}}
/>
</View>
);
}
mount(App);
| Style | Type | Description |
|---|---|---|
transitionProperty | string | What animates, comma-separated: "all", "colors", "opacity", "transform", "box-shadow", "border-radius", … |
transitionDuration | number | "150ms" | "0.2s" | Milliseconds as a number, or a CSS time. |
transitionDelay | number | "150ms" | "0.2s" | Before it starts. |
transitionTimingFunction | "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 does not transition: for size and position changes use LayoutAnimation.
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 active: variant it gives pressed states that animate:
<View onPress={save} class="rounded-xl bg-blue-600 px-5 py-3 transition duration-150 active:scale-95 active:bg-blue-800">
<Text class="font-semibold text-white">Save</Text>
</View>
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 class="size-8 rounded-full border-4 border-zinc-300 border-t-blue-600 animate-spin" />
<View class="h-4 w-40 rounded bg-zinc-200 animate-pulse" />
| Style | Type | Description |
|---|---|---|
animationName | "none" | "spin" | "ping" | "pulse" | "bounce" | The animation. |
animationDuration | number | "1s" | … | One iteration. |
animationDelay | number | "200ms" | … | Before it starts. |
animationTimingFunction | as transitionTimingFunction | The curve. |
animationIterationCount | number | "infinite" | Default "infinite". |
Tailwind: animate-spin, animate-ping, animate-pulse, animate-bounce, animate-none.
#Native values
A native value is a number that lives on the UI thread. Create one with createNativeValue from nsh/animated, bind it to styles, and animate it; the bound views follow it every frame without re-rendering.
import { mount, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";
function App() {
const x = createNativeValue(0);
const opacity = createNativeValue(1);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 24 }}>
<View style={{ width: 60, height: 60, borderRadius: 12, backgroundColor: "#0969da", opacity, transform: [{ translateX: x }] }} />
<View
onPress={async () => {
await x.timing(240, { duration: 600, easing: Easing.out(Easing.cubic) });
await opacity.timing(0.3, { duration: 200 });
x.spring(0);
opacity.timing(1);
}}
>
<Text style={{ color: "#0969da" }}>Play</Text>
</View>
</View>
);
}
mount(App);
What can be bound: opacity, backgroundColor (through an interpolation to colors), and the translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY and rotateZ steps of transform. Native 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, so a binding never breaks because a JS variable went out of scope. Create native values in the component that owns them (not at module level when every instance needs its own).
#NativeValue methods
| Method | Description |
|---|---|
set(value) | Jumps to value, stopping any animation. |
stop() | Stops the running animation where it is. |
timing(to, { duration?, easing?, delay? }) | Animates to to over duration ms (default 300, easing "easeInOut"). |
spring(to, { stiffness?, damping?, mass?, velocity? }) | Springs to to (defaults: stiffness 170, damping 26, mass 1). |
decay({ velocity?, deceleration? }) | Coasts from velocity (units per second) and slows down (deceleration 0.998 per ms by default). |
Each animation returns a Promise<boolean>: true when it finished, false when something stopped it (stop(), set(), or a new animation on the same value). Starting a new animation on a moving value interrupts the old one smoothly: a spring or decay without velocity continues with the value’s current velocity.
// Spring settings: lower damping overshoots more, higher stiffness is faster.
x.spring(200, { stiffness: 180, damping: 8 }); // wobbly
x.spring(200, { stiffness: 400, damping: 30 }); // stiff, no overshoot
// A fling that coasts to a stop, from a pan's release velocity.
<View pan={{ x }} onPanEnd={(e) => x.decay({ velocity: e.velocityX })} />
#Interpolation
value.interpolate({ inputRange, outputRange }) 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.
const t = createNativeValue(0);
<View
style={{
backgroundColor: t.interpolate({ inputRange: [0, 1, 2], outputRange: ["#54aeff", "#8250df", "#cf222e"] }),
opacity: t.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }),
transform: [
{ rotate: t.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "180deg"] }) },
{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1.2], easing: Easing.out(Easing.back()) }) },
],
}}
/>
| Option | Description |
|---|---|
inputRange | Increasing input stops. |
outputRange | One output per stop: numbers, colors ("#rrggbb", "rgba(…)", named) or angles ("45deg", "1rad"). |
extrapolate | Outside the input range: "clamp" (default), "extend" (continue the end segments) or "identity" (pass the input through). |
extrapolateLeft, extrapolateRight | The same, for one side. |
easing | Shapes 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) (never negative for a positive b, e.g. angles in 0..360), where b is a node or a number. Animated.add, subtract, multiply, divide and modulo are the same as functions.
node.diffClamp(min, max) 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:
const scroll = createNativeValue(0);
const header = scroll.diffClamp(0, 56);
<Scroll style={{ flex: 1 }} scrollValue={scroll}>…</Scroll>
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: 56, transform: [{ translateY: header.mul(-1) }] }}>…</View>
#Composite animations
Animated builds animations out of steps and runs the whole tree natively: each step starts on the UI thread the moment the previous one ends, with no gap and no JS in between.
import { Animated, createNativeValue, Easing } from "nsh/animated";
const ys = [0, 1, 2, 3, 4].map(() => createNativeValue(0));
const fade = createNativeValue(1);
const hop = Animated.stagger(
80,
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 }),
]),
),
);
const breathe = Animated.loop(
Animated.sequence([
Animated.timing(fade, { toValue: 0.3, duration: 400 }),
Animated.timing(fade, { toValue: 1, duration: 400 }),
]),
);
hop.start(({ finished }) => console.log(finished ? "landed" : "stopped"));
| Function | Description |
|---|---|
Animated.timing(value, { toValue, duration?, easing?, delay? }) | A timing step. |
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? }) | A spring step. |
Animated.decay(value, { velocity?, deceleration? }) | A decay step. |
Animated.sequence(list) | One after another. |
Animated.parallel(list, { stopTogether? }) | All at once. |
Animated.stagger(ms, list) | All at once, each starting ms after the previous one. |
Animated.delay(ms) | Waits. |
Animated.loop(animation, { iterations?, resetBeforeIteration? }) | Repeats (forever by default). |
A CompositeAnimation has start(callback?) (also a promise of finished; 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.
#Easing
Easing from nsh/animated gives the standard easing curves. They are data, evaluated natively, not JS functions.
| Easing | Description |
|---|---|
Easing.linear, Easing.ease | Constant speed; CSS’s ease. |
Easing.quad, Easing.cubic, Easing.poly(n) | t², t³, tⁿ. |
Easing.sin, Easing.circle, Easing.exp | Sinusoidal, circular, exponential. |
Easing.back(s?) | Pulls back before going (overshoot s, 1.70158 by default). |
Easing.elastic(bounciness?) | Springy overshoot. |
Easing.bounce | Bounces at the end. |
Easing.bezier(x1, y1, x2, y2) | A cubic Bézier, as in CSS. |
Easing.in(e), Easing.out(e), Easing.inOut(e) | The curves are “in” curves; out and inOut turn them around. |
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].
x.timing(1, { easing: Easing.out(Easing.back()) });
x.timing(1, { easing: "easeOut" });
x.timing(1, { easing: [0.2, 0.8, 0.2, 1] });
#LayoutAnimation
LayoutAnimation.configureNext(config) 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.
import { signal } from "nsh";
import { LayoutAnimation } from "nsh/animated";
const items = signal([1, 2, 3]);
let next = 4;
const add = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
items.set([next++, ...items()]);
};
const remove = () => {
LayoutAnimation.easeInEaseOut();
items.set(items().slice(1));
};
| Member | Description |
|---|---|
configureNext(config, onEnd?) | Animates the next commit’s changes; onEnd runs when done. |
create(duration, type?, property?) | A config with one curve for all three phases. |
Presets.easeInEaseOut, Presets.linear, Presets.spring | Ready-made configs. |
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?) | configureNext with a preset. |
Types, Properties | The names, for building configs. |
A config is { duration, create?, update?, delete? }: create for inserted views, update for moved or resized ones, delete for removed ones. Each phase is { type?, property?, duration?, delay?, springDamping? }, 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) sets the spring’s overshoot.
LayoutAnimation.configureNext({
duration: 300,
create: { type: "easeOut", property: "opacity" },
update: { type: "spring", springDamping: 0.8 },
delete: { type: "easeIn", property: "scaleXY" },
});
#What runs where
| Runs on | JS per frame | |
|---|---|---|
| Transitions, keyframe animations | UI thread | none |
timing / spring / decay, composites | UI thread | none; JS hears when they end |
interpolate, arithmetic, diffClamp | UI thread | none |
scrollValue, pan / pinch / rotation | UI thread | none; JS hears start and end |
| LayoutAnimation | UI thread | none |
requestAnimationFrame loops, onScroll handlers, PanResponder | JS, then a re-render | every frame |
Reach for requestAnimationFrame (a global, following the display) only for what cannot be described ahead of time, such as a physics simulation in JS. Anything that can be a native value should be one.
#Reduced motion
When the user asks for less motion (iOS “Reduce Motion”, Android “Remove animations”), reduceMotion() from nsh/accessibility is true and the motion-reduce: / motion-safe: Tailwind variants apply. nsh does not turn your animations off by itself; use them to choose a calmer alternative:
import { View } from "nsh";
import { reduceMotion } from "nsh/accessibility";
const open = () => (reduceMotion() ? panel.set(0) : panel.spring(0));
<View class="motion-safe:animate-bounce" />
See Accessibility.
#Related
nsh/animated: the reference.- Touch and gestures: driving native values with the finger.
- Scrolling:
scrollValue. - Canvas: native values in drawings.
- Style reference: the
transition*andanimation*keys.