Everything animated in a preview runs on loom’s own frame loop — the scheduler’s RunService
signals, the same ones your Roblox code connects to. There is no CSS transition anywhere in the
renderer, so what you see is the interpolation your code actually computes.
Three layers, from lowest to highest:
| Layer | Use it for |
|---|---|
| Bindings | A value you drive yourself, per frame, without re-rendering. |
TweenService | The engine’s own tween API, with the same easing enums. |
@rbxts/ripple | Springs, tweens and motion as React hooks. |
Bindings
createBinding, useBinding and joinBindings are exported from @rbxts/react, and any host
prop accepts either a plain value or a Binding of one:
import { useBinding } from "@rbxts/react";
function Follower() { const [position, setPosition] = useBinding(UDim2.fromOffset(0, 0));
return ( <frame Size={UDim2.fromOffset(80, 80)} Position={position} Event={{ InputChanged: (_, input) => setPosition(UDim2.fromOffset(input.Position.X, input.Position.Y)), }} /> );}A bound prop is written straight onto the live instance by the renderer, bypassing React entirely. A 60fps animation is 60 property writes and zero renders.
There is exactly one kind of binding in a preview. The implementations come from @loom-dev/react,
which is what the renderer resolves. A binding minted by useBinding and one minted by ripple’s
useSpring are the same object as far as the renderer is concerned.
TweenService
The engine’s tween API, imported the way you already import it:
import { useEffect, useRef } from "@rbxts/react";import { TweenService } from "@rbxts/services";
function SlideIn() { const ref = useRef<Frame>();
useEffect(() => { const frame = ref.current; if (!frame) return;
const info = new TweenInfo(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out); const tween = TweenService.Create(frame, info, { Position: UDim2.fromScale(0.5, 0.5), });
tween.Play(); return () => tween.Cancel(); }, []);
return <frame ref={ref} Position={UDim2.fromScale(0.5, 1.2)} />;}TweenInfo’s positional arguments are the engine’s, in the engine’s order:
(Time, EasingStyle, EasingDirection, RepeatCount, Reverses, DelayTime).
| Surface | Supported |
|---|---|
TweenService | Create, GetValue. |
GuiObject | TweenPosition, TweenSize, TweenSizeAndPosition — the older per-instance API, since 0.12.0. |
Tween | Play, Pause, Cancel, Completed, PlaybackState. |
TweenInfo | DelayTime, RepeatCount, Reverses, and every EasingStyle / EasingDirection. |
| Interpolated types | number, Color3, UDim, UDim2, Vector2. |
Tweens advance on the scheduler’s frame signal, so a tweened write flushes to the DOM like any other property write. Pausing the scheduler pauses the tween.
@rbxts/ripple
@rbxts/ripple and @rbxts/react-ripple both work with no configuration. Loom answers them
with a port of the published implementation rather than a stub, because the package ships a Luau
runtime a browser cannot execute. See
Package compatibility for
why.
import { config, useSpring } from "@rbxts/react-ripple";
function AnimatedButton() { const [offset, spring] = useSpring(0, config.stiff);
return ( <textbutton Size={offset.map((value) => UDim2.fromOffset(200 + value, 50 + value))} Event={{ MouseEnter: () => spring.setGoal(10), MouseLeave: () => spring.setGoal(0), }} /> );}The spring integrator, the easing curves, the Oklab colour interpolation and the rest thresholds all follow the Luau source. A component animates the way it does in Roblox.
Exports
createSpring, createTween, createMotion, config, easing, springScheduler,
tweenScheduler, motionScheduler, and the useSpring / useTween / useMotion hooks — which also
re-export the core, as the real package does. Every published config preset and every published
easing curve is implemented.
Controller methods
Matching the published .d.ts:
| Controller | Methods |
|---|---|
| All three | getPosition getGoal setPosition setGoal onChange onComplete step idle configure start stop destroy |
Spring / Motion | getVelocity setVelocity |
Spring | impulse halt |
Tween | getFrom setFrom |
Motion | spring tween |
Every documented option is honoured. Springs take start, tension, friction, mass,
dampingRatio, frequency, precision, restVelocity, position, velocity and impulse.
Tweens take start, easing, duration, repeats, reverses and position. Motion takes
start, spring and tween.
Values
number, Vector2, Vector3, Color3, UDim, UDim2, Rect, and records of numbers — which
accept partial goals, so keys you leave out do not move. Color3 interpolates through Oklab, and
UDim / UDim2 offsets round to integers, both as Roblox does.
CFrame throws rather than animating:
[loom] Ripple compatibility does not yet support animating CFrameLoom’s CFrame carries position only and the Scene IR has no property slot for one, so an
interpolation could not reach the screen. Anything else throws by name too, instead of freezing or
producing a corrupt value. A string, a record of non-numbers. Subpaths like @rbxts/ripple/foo are
not covered.
Both in loom’s favour:
destroy()also drops the controller’sonChange/onCompletecallbacks. Upstream only stops it, which leaves a torn-down controller able to call into an unmounted component.- The hooks ignore a changed
initialOptionsafter mount — exactly as upstream does. Recreating a controller mid-animation would drop its velocity and its subscribers. Callcontroller.configure(...)to retune one in place.
One frame loop
Every controller shares a single RunService.Heartbeat connection, released the moment the last one
settles. Spring, tween and motion alike. Values are pushed into a binding, so the renderer writes
them onto the live instance and React never re-renders.
Loom’s scheduler is driven by requestAnimationFrame, which browsers do not fire for a tab that is
not visible. An animation in a hidden or backgrounded preview is not broken, it is not being stepped. It resumes where it left off when the tab comes forward.