#Animation

Vision's springs are tween-driven. They take a TweenInfo and read their curve from TweenService:GetValue, so the shape is not an approximation of TweenService - it is TweenService.

Only the timeline is Vision's own, and it reproduces real Tween behaviour including delay, repeat and reverses.

#Linker

Every Spring* call returns one.

export type Linker = {
    Cancel: (self: Linker) -> (),
    Await: (self: Linker) -> (),
    Finished: boolean,
}

#Await

Yields the calling thread until the link finishes or is cancelled. Returns immediately if it is already done.

task.spawn(function()
    Scope:SpringInstance(Out, Panel, { Size = Big }):Await()
    Scope:SpringInstance(Back, Panel, { Size = Normal })
end)

#Cancel

Stops the link where it is. The value keeps whatever it last held.

#Finished

true once the link has completed or been cancelled. A finished link drops its claims, its callback and its runtime reference, so nothing is retained.

#Lifetime

A link is owned by the runtime while it runs, so you do not have to keep the Linker. Start an animation, drop the handle, and it plays to completion.

Scope:SpringEvent(TweenInfo.new(0.6), Interface, { Fill = 0.8 })

When it finishes it removes itself from the runtime, releases its claims, and drops its callback and targets, so nothing is retained afterwards. Keep the handle only if you want to Await or Cancel it.

#Destroyed instances

If an instance being animated is destroyed, the link driving it is cancelled on the spot. That matters most for a tween with RepeatCount = -1, which would otherwise write to a dead instance forever and keep it alive.

const Link = Scope:SpringInstance(Endless, Spinner, { Rotation = 90 })

Spinner:Destroy()
print(Link.Finished)   --> true

Only the link driving that instance stops. Other animations are untouched.

Tip

Vision:Cleanup already cancels the animations driving its own tree, so this is about instances destroyed from outside Vision.

#TweenInfo support

Every field is honoured.

field behaviour
Time length of one direction
EasingStyle, EasingDirection passed to TweenService:GetValue
DelayTime held at the start value, re-applied before every repeat
RepeatCount extra plays after the first; -1 never ends
Reverses plays forward then back, ending at the start value

One cycle is DelayTime + Time * (Reverses and 2 or 1), and a tween runs RepeatCount + 1 of them.

#Repeats land on the goal

At a cycle boundary the goal value is emitted on that frame, and the next cycle starts from it. This matters - without it a repeating tween never visually reaches its target.

This is real TweenService behaviour, confirmed frame by frame against a live Tween:

   t     roblox   vision
0.283s    94.63    94.63
0.299s   100.00   100.00   <- the goal, on the boundary frame
0.317s     5.32     5.32
0.333s    11.30    11.30

A consequence worth knowing: because the next cycle starts from the boundary frame, a repeating tween's total runtime is frame-quantised and drifts slightly past the arithmetic ideal. Roblox does the same.

#Interpolation

type behaviour
number linear
Vector2, Vector3, UDim2, CFrame, Color3 Roblox's own :Lerp
UDim scale linear, offset rounded
Rect, NumberRange component-wise
Vector2int16, Vector3int16 component-wise, rounded
NumberSequence, ColorSequence keypoint-wise
string typewriter, UTF-8 safe
anything else switches at the halfway point

That last row covers booleans and EnumItems, and matches TweenService, which flips values it cannot interpolate at the midpoint rather than at the end.

#Beyond TweenService

NumberSequence, ColorSequence and string are not tweenable by TweenService. Vision animates them anyway.

Sequences interpolate keypoint by keypoint - time, value and envelope - and never insert or remove a waypoint. If the two sequences have different keypoint counts there is nothing sensible to interpolate, so the value switches at the halfway point instead.

Strings type out: the visible length moves from the start string's length to the goal's, sliced from the goal on UTF-8 boundaries.

Scope:SpringEvent(TweenInfo.new(0.45), Interface, { Status = "Ready" })

#Overshoot

Back and Elastic return alpha outside 0..1, and Vision does not clamp it - an overshooting curve overshoots the value, as it should. Back/Out peaks at 1.100 at 0.35s of a 0.6s tween.

Leave room for it. A bar tweening to 1.0 scale with Back will pass its track; tween to 0.8 instead.

#Retargeting

Starting a spring on a value that is already animating cancels the old link and starts from the current value at zero velocity. A tween has a fixed duration and no momentum, so an interrupted animation stops dead before re-accelerating.

If you need motion that survives interruption, that is what a physics spring is for - see the comparison.

#Frame rate

Physics springs are solved in closed form rather than stepped, so the position after a given amount of elapsed time is the same no matter how that time was split into frames.

same spring, same total time, 40 frames
  even frames    97.880964
  uneven frames  97.880964
  drift          0.000000000

This matters more than it sounds. A substepping solver changes its step size as the frame time moves, which changes its own error frame to frame - and that shows up as jitter on anything small or slow moving, like a progress bar.

It also means a long frame is handled exactly rather than approximated, so a hitch cannot make a spring overshoot or blow up.