Motion values
For a retained spring that follows a changing value, see springValue().
A motion value is a number that animates natively and drives every view property bound to it. One value can move a sheet, darken a backdrop and shrink the page behind it, all on one native clock:
import { animate, bind, motionValue } from '@rafaelaugusto/jolt'
const progress = motionValue(0)
bind(page, { scale: progress.to([0, 1], [1, 0.92]) })
bind(backdrop, { opacity: progress.to([0, 1], [0, 0.45]) })
bind(sheet, { y: progress.to([0, 1], [200, 0]) })
animate(progress, 1, 'snappy')Mapping
to(input, output) maps a value through input and output ranges, like React Native's interpolate: piecewise linear, with the endpoint outputs held outside the input range. Pass { clamp: false } to extend the endpoint segments instead, so a spring's overshoot carries through. Inputs can increase or decrease, but cannot change direction. They must be finite, with at least two inputs and one output per input.
const y = progress.to([0, 0.4, 1], [600, 120, 0])
const tint = progress.to([0, 1], ['#18181B', '#FACC15']) // colors mix in OKLab
const bounce = progress.to([0, 1], [0, 300], { clamp: false })For a sheet whose offset goes from closed to 0, map that direction directly. Each output stays paired with its input, including colors:
const closed = 380
const offset = motionValue(closed)
const open = offset.to([closed, 0], [0, 1])
bind(sheet, { y: offset })
bind(page, { scale: open.to([0, 1], [1, 0.9]) })
bind(backdrop, { opacity: open.to([0, 1], [0, 0.5]) })
animate(offset, 0, 'snappy')This gives the same result as offset.to([0, closed], [1, 0]), without changing your arrays. Numeric extrapolation works in both directions; colors always clamp. Invalid ranges such as [0, 1, 0] throw when to() is called.
Repeated inputs retain their existing step behavior after sorting the pairs into ascending order: at an interior repeated stop, the lower segment's output wins; just above it, the last output at that stop wins. Clamping takes precedence at the endpoints. An all-equal range holds its first output at or below the stop and its last above; with clamp: false, the output at the exact stop is the second output instead.
A mapped number can be mapped again. Color outputs bind to backgroundColor, borderColor and color; number outputs bind to everything else animate() takes, with scale setting both axes. A value can also be bound as is: bind(dot, { x: offset }).
Animating and setting
animate(value, to, transition) takes a number or keyframes and the same transitions as animate(): tweens, springs, repeat, repeatType, repeatDelay, delay and easeTrack. It returns a PlaybackAnimation, including pause, play and finite progress. Jolt samples the value's curve once, maps it through each binding, and plays one native track per bound property, all started together. Native bindings need no per-frame JavaScript callbacks.
animate(progress, 1, 'snappy')
animate(progress, [0, 1, 0.6], { duration: 700, times: [0, 0.5, 1] })
animate(pulse, 1, { duration: 900, repeat: Infinity, repeatType: 'reverse' })An animation that interrupts another starts from the value on screen, with its velocity, so every bound view turns around together. set(value, number) applies a value to every bound view at once. stop(value) holds the value and its views where they are. value.get() reads the value now, while it animates too.
With reduced motion, bound transforms jump to their end while opacity and colors still animate. The time scale applies once, to the value's curve.
Observing outside views
value.subscribe((value, time) => ...) follows a value from JavaScript, including numeric and color derivations and scroll values. It delivers the current value immediately and returns an idempotent unsubscribe function. Pass { immediate: false } to receive only subsequent changes.
const release = progress.subscribe((value, time) => {
// For example, update a SceneKit pose or a custom native renderer.
stage.setProgress(value)
})
const releaseColor = progress.to([0, 1], ['black', 'white']).subscribe((color) => {
stage.setTint(color)
})
// Ownership, including scope suspension/resume:
scope.use(() => progress.subscribe((value) => stage.setProgress(value)))
release()
releaseColor()Updates share a frame source backed by NativeScript's CADisplayLink/Choreographer scheduling. Each notification batch captures all observed sources before invoking listeners and supplies the same native-clock timestamp in milliseconds. Repeated set() calls before a frame coalesce to their latest value; unchanged mapped outputs do not notify. Completion and stop deliver the final value on the next frame if it changed. A write made inside a callback belongs to the next batch.
Callbacks execute in JavaScript. They can drive objects outside NativeScript views, but cannot run while that JavaScript thread is blocked. This is separate from the planned native property adapter. Native view bindings continue to play independently of these callbacks.
Unsubscribing takes effect even during delivery and does not stop the animation. The frame source becomes idle after observed sources settle and pending writes are delivered. While the app is in the background, delivery stops; resuming samples the current value without replaying missed frames. Listener errors go to NativeScript's error trace and do not stop other listeners. Use scope.use() or call the returned function when the consumer is destroyed.
Reduced motion keeps the scalar track's timing; bound transforms can already be at their end while subscribers and bound colors still follow the scalar. External consumers should honor reducedMotion() themselves when applying spatial motion. The Android system animator duration scale still applies to native clocks, including when animators are disabled.
Layers on a value
A named layer adds a scalar offset to a motion value before its mappings. The base animation keeps its own time and velocity while a charge pulse changes every consumer together:
const charge = motionValue(0)
bind(car, {
y: charge.to([0, 1], [0, -20]),
scale: charge.to([0, 1], [1, 1.08]),
backgroundColor: charge.to([0, 1], ['#14b8a6', '#6366f1']),
})
animate(charge, 1, { duration: 4000 })
animate(charge, [0, 0.12, -0.12, 0], {
duration: 1200,
repeat: Infinity,
layer: 'pulse',
})Value layers always add, including when a binding maps the result to scale, opacity or color. Their neutral value is 0. This differs from view-property layers, which multiply scale and opacity. Clamping, piecewise mappings and color mixing see the combined scalar; get() and subscribe() see that same composition.
Each name owns its animation and velocity. Retargeting that name replaces only that layer; animating without layer replaces only the base. A completed layer holds its final contribution until removed. The returned PlaybackAnimation also supports pause, play and finite progress for that channel; pausing holds its contribution while other channels continue. Stopping an active layer's handle removes its contribution; stopping the base handle holds the base and leaves layers running. A settled handle does nothing.
stop(charge, { layer: 'pulse' }) // removes even a completed contribution
// Or ease the contribution back to zero first:
await animate(charge, 0, { duration: 250, layer: 'pulse' })
stop(charge, { layer: 'pulse' })
stop(charge) // freezes the combined value as the new base, clears all layers
set(charge, 0) // cancels all channels and replaces the valueDragging a motion value changes only its base. Bounds, snap points, controller position and drag events use base units; additive layers can move the combined result beyond those bounds. A layer keeps playing during the drag and release. Scroll values remain read-only inputs and do not expose animation layers.
Bindings play a compiled native composition. It is rebuilt on channel changes and drag input, not on every frame. Endless integer-millisecond periods use a common native loop up to 30 seconds; longer or fractional combinations, and finite compositions longer than four seconds, use windows rebuilt at their boundaries. Native clocks keep each channel's phase. Opting into subscribe() additionally schedules JavaScript notifications.
Binding
bind(views, bindings) gives the views the values' current state at once and keeps them following. It returns a cleanup function: after it, the views stay where they are. motionScope().use(() => bind(...)) ties a binding to a scope.
A value outlives its views. A view that is not loaded yet takes its value when it loads, and joins an animation in progress at the point it has reached. A regular animate() or set() on a bound property takes that property over at its current value and velocity; the value keeps driving its other bindings. stop(view) stops the motion on screen but keeps the binding.
Vue
useMotionValue() creates a value that stops when the component unmounts. useBinding() returns a template ref that binds on mount and releases on unmount, so an element recreated by v-if picks the value up again:
<script setup lang="ts">
import { animate } from '@rafaelaugusto/jolt'
import { useBinding, useMotionValue } from '@rafaelaugusto/jolt/vue'
const progress = useMotionValue(0)
const sheet = useBinding({ y: progress.to([0, 1], [200, 0]) })
const toggle = (open: boolean) => animate(progress, open ? 1 : 0, 'snappy')
</script>
<template>
<GridLayout :ref="sheet" />
</template>Scroll offsets
scrollValue(scrollView) is a ScrollView's offset as a value, in DIPs. Bind it like any motion value; each scroll event writes the bound properties directly, with no animation in between:
import { bind, scrollValue } from '@rafaelaugusto/jolt'
const offset = scrollValue(scroll)
bind(header, { y: offset.to([0, 90], [0, -64]) })
bind(title, { scale: offset.to([0, 90], [1, 0.72]), y: offset.to([0, 90], [0, 30]) })
bind(subtitle, { opacity: offset.to([0, 50], [1, 0]) })
bind(progress, { scaleX: offset.to([0, 700], [0, 1]) }){ axis: 'x' } follows the horizontal offset. The value starts from the current offset, and offset.ref(scrollView) follows another ScrollView, or none with null. offset.dispose() stops following and leaves bound views where they are. In Vue, useScrollValue() returns a value whose ref goes on the ScrollView's template ref, and stops with the component:
<script setup lang="ts">
const offset = useScrollValue()
const header = useBinding({ y: offset.to([0, 90], [0, -64]) })
</script>
<template>
<ScrollView :ref="offset.ref">...</ScrollView>
<GridLayout :ref="header" />
</template>Scroll events arrive in JavaScript, as for scrollLinked(). With five bound properties, one event costs about 0.25 to 0.3 ms on the iOS simulator and the Android emulator, about half of the equivalent scrollLinked() setup on Android; see the measurements.
Dragging
drag(view, { axis, value }) writes the finger's position into a value instead of moving the view, then plays the release on the value. Everything bound to it follows the finger and the fling together. The gesture is read on the view you pass; bind that view too if it should move:
import { bind, drag, motionValue } from '@rafaelaugusto/jolt'
const closed = 380
const y = motionValue(closed)
bind(sheet, { y })
bind(page, { scale: y.to([0, closed], [0.9, 1]) })
bind(backdrop, { opacity: y.to([0, closed], [0.5, 0]) })
bind(hint, { opacity: y.to([closed / 2, closed], [0, 1]) })
drag(sheet, {
axis: 'y',
value: y,
bounds: { top: 0, bottom: closed },
snap: [0, closed / 2, closed],
rubberband: true,
})axis is 'x' or 'y'. Bounds, snap points and the dragged axis in events are in the value's units; the other axis reads 0. Everything else works as in drag(): rubberband, friction, callbacks and the controller. The release is the same curve, sampled once and played natively by every bound property, with nothing in JavaScript until it ends. onRelease receives an Animation rather than a PlaybackAnimation. Touching the view during a release, or during animate(value, ...), stops the value where it is, and the gesture carries on from there.
Each pan event writes the bound properties directly. With six bound properties, an event costs about the same as writing them by hand in onMove: around 0.8 ms on the iOS simulator and 0.25 to 0.35 ms on the Android emulator; see the measurements. In Vue, start the drag from useMotionScope() in onMounted, as the demo's Drag values screen does.
What comes next
Value layers and view-property layers can be combined. Native evaluation, where measurements call for it, is planned in the motion values RFC.