Apricot's rule is: React handles structure, never per-frame changes. Use React state to decide what is in the scene. Use shared values, useFrame and behaviors to decide how it moves. A scene that animates through React re-renders will stutter; one that animates through the paths below runs at the display's refresh rate.
1. Plain props#
Passing a new plain value is fine for occasional changes: a button press, a selection, a mode switch.
import { Box, Scene } from "@apricot/react";
import { useState } from "react";
export function Selected() {
const [selected, setSelected] = useState(false);
return (
<Scene style={{ flex: 1 }}>
<Box
position={[0, selected ? 0.5 : 0, 0]}
color={selected ? "#22c55e" : "#cfd3d8"}
onPress={() => setSelected((s) => !s)}
/>
</Scene>
);
}A new position jumps there at once. Some props animate to a new plain value by themselves:
| Prop | Animates over | Default time |
|---|---|---|
Material color, roughness, metalness |
transition in the material options |
0.4 s |
<Model joints> values |
the joint's transition |
0.6 s |
<Model animations> progress (a number) |
fixed | 0.6 s |
<Model morphs> weights |
fixed | 0.4 s |
<CameraRig view> |
transition |
0.9 s |
<Environment name> (or sky, boxes) |
transition |
0.6 s |
<Daylight time> |
transition |
1.5 s |
2. Shared values#
Every animatable prop accepts a Reanimated shared value (useSharedValue, useDerivedValue) as well as a plain value. When a shared value changes, Apricot copies it to the render loop without a React render and without blocking the JS thread, then wakes the loop. Gestures, springs, timings, decays and sensors all run on the UI thread and reach the 3D scene with 2–3 ms of latency.
import { Scene, Sphere } from "@apricot/react";
import { useEffect } from "react";
import { useDerivedValue, useSharedValue, withRepeat, withSpring } from "react-native-reanimated";
export function Bounce() {
const height = useSharedValue(0);
useEffect(() => {
height.value = withRepeat(withSpring(1.2, { damping: 6 }), -1, true);
}, [height]);
const position = useDerivedValue<readonly [number, number, number]>(() => [0, height.value, 0]);
return (
<Scene style={{ flex: 1 }}>
<Sphere radius={0.5} position={position} material="paint" color="#e3a21a" />
</Scene>
);
}Vectors (position, rotation, a per-axis scale) are tuples, so derive them with useDerivedValue<readonly [number, number, number]>(…) from one or more numeric shared values.
Which props take shared values#
| Component | Animatable props |
|---|---|
Every object (Group, Mesh, shapes, Model, Primitive, Instances, assets) |
position, rotation, scale |
<Spin> and any defineBehavior component |
every prop in its defaults (speed, axis) |
<AmbientLight>, <HemisphereLight> |
intensity |
<DirectionalLight>, <SpotLight> |
intensity, position, target |
<PointLight> |
intensity, position |
<Environment> |
intensity, rotation |
<Daylight> |
time |
<Vehicle> |
throttle, steer |
<FollowPath> |
distance |
<Model> |
each value in joints, animations (progress) and morphs |
Props not in this table (colours, sizes, materials, names, callbacks) are plain values: change them with React state.
3. useFrame#
useFrame runs a function before every rendered frame. Use it for logic that is not a single prop: physics of your own, many objects, procedural motion.
import { Box, Scene, type SceneObject, useFrame } from "@apricot/react";
import { useRef } from "react";
function Wobbler() {
const box = useRef<SceneObject>(null);
useFrame(({ time }) => {
if (!box.current) return true;
box.current.rotation.z = Math.sin(time * 3) * 0.3;
return true; // still animating: keep rendering
});
return <Box ref={box} size={1.2} />;
}
export function Wobble() {
return (
<Scene style={{ flex: 1 }}>
<Wobbler />
</Scene>
);
}The callback receives { time, delta, frame }:
| Field | Meaning |
|---|---|
time |
Seconds since the loop started. |
delta |
Seconds since the previous rendered frame. 0 on the first frame, capped at 0.1. Multiply speeds by it. |
frame |
The number of frames rendered so far. |
Return true while animating and false (or nothing) when finished. In "demand" mode the loop sleeps once no callback returns true and nothing else changed. A motion that ends by itself should return false when it ends:
import { Box, type SceneObject, useFrame, useScene } from "@apricot/react";
import { useEffect, useRef } from "react";
export function DecayingSpin({ kicks }: { kicks: number }) {
const scene = useScene();
const cube = useRef<SceneObject>(null);
const speed = useRef(0);
useEffect(() => {
if (kicks === 0) return;
speed.current += 6;
scene.invalidate(); // wake the loop: this change happened outside useFrame
}, [kicks, scene]);
useFrame(({ delta }) => {
if (!cube.current || speed.current === 0) return false;
cube.current.rotation.y += speed.current * delta;
speed.current *= 0.04 ** delta; // loses about 96% of its speed per second
if (speed.current < 0.01) speed.current = 0;
return speed.current > 0;
});
return <Box ref={cube} size={1.6} />;
}useFrame runs on the JS thread. To change a single prop from a gesture, a shared value is simpler and has lower latency.
4. Behaviors#
A behavior is a component that moves its children every frame. <Spin> is one. Nest behaviors and groups to combine motions:
import { MoneyStack } from "@apricot/assets";
import { Group, Scene, Spin } from "@apricot/react";
export function Combined({ rotation }: { rotation: readonly [number, number, number] }) {
return (
<Scene style={{ flex: 1 }}>
<Spin speed={0.6}>
{/* the steady spin and the user's swipe add up */}
<Group rotation={rotation}>
<MoneyStack bills={40} />
</Group>
</Spin>
</Scene>
);
}Write your own with defineBehavior: see Custom geometry and behaviors.
Choosing#
| Situation | Use |
|---|---|
| A tap toggles something | React state and plain props |
| A gesture moves, turns or opens something | A shared value written by the gesture |
| A spring or timing animation | withSpring / withTiming on a shared value |
| Constant motion (spinning, floating) | <Spin> or a behavior |
| Motion that depends on time or on many objects | useFrame with refs |
| Hundreds of moving copies | useFrame with <Instances ref> and ref.set(index, item) |
Related#
- Scenes: what wakes the loop.
- Gestures and interaction.
- Hooks reference:
useFrame,useAnimatable,useScene.