Skip to content

Home › Motion

Spring ​

Flux.spring(target, frequency?, damping?, initialVelocity?) returns a reactive node driven by damped harmonic motion. When its target changes mid-flight, a spring preserves its momentum and smoothly redirects toward the new goal instead of snapping.

luau
local xPosition = Flux.spring(0, 2, 0.8)

local ui = Flux.new "Frame" {
    Size = UDim2.fromOffset(100, 100),
    Position = function()
        return UDim2.fromOffset(xPosition(), 0)
    end,
}

-- Smoothly animate toward 200
xPosition(200)

Basic Usage ​

Flux.spring takes up to four arguments:

  1. target: The initial value (or reactive source) the spring moves toward.
  2. frequency (optional, default 1): Speed in cycles per second. Higher values move faster.
  3. damping (optional, default 1): How the spring settles:
    • 1 (Critically Damped): Quickly approaches the target and stops without overshoot.
    • < 1 (Underdamped): Overshoots the target and bounces before settling.
    • > 1 (Overdamped): Eases sluggishly into the target without bouncing.
  4. initialVelocity (optional): Starting velocity in the target's type (for example, a Vector2 for a Vector2 spring). Omit to start at rest.

A spring acts like a standard Signal. Read it with spring() or :get(), retarget it with spring(newGoal) or :set(newGoal), and bind it directly to Instance properties.

Reactive Targets ​

Pass a Signal, Computed, or plain function () -> T as target to retarget the spring automatically whenever its dependencies change:

luau
local isHovering = Flux.signal(false)

local buttonSize = Flux.spring(function()
    return if isHovering() then UDim2.fromScale(1.1, 1.1) else UDim2.fromScale(1, 1)
end, 3, 0.75)

local myButton = Flux.new "TextButton" {
    Size = buttonSize,
    Text = "Hover Me",
    MouseEnter = function() isHovering(true) end,
    MouseLeave = function() isHovering(false) end,
}

frequency and damping also accept reactive nodes or functions, so you can retune the spring's physics on the fly.

Velocity Control: Impulse & Set Velocity ​

Springs expose two methods to manipulate velocity directly without changing the target:

  • spring:impulse(delta): Adds delta (in units per second) to the current velocity. Multiple impulses accumulate.
  • spring:setVelocity(velocity): Replaces the current velocity outright.

Both take a value of the spring's datatype and wake a settled spring immediately:

luau
local y = Flux.spring(0, 4, 0.6)

-- Kick upward at 800 px/s, then let the spring settle back to 0
local function onHit()
    y:impulse(-800)
end

-- Cancel in-flight momentum
local function onGrab()
    y:setVelocity(0)
end

Snap & Instant Placement ​

When you need to reposition a spring immediately without a transition (for example, on initial open or while dragging), use :snap() or :setCurrent():

  • spring:snap(value): Sets both the target and current animated value to value and zeroes velocity, putting the spring at rest immediately.
  • spring:setCurrent(value): Sets the current animated value to value while preserving the existing target and velocity, waking the spring so it animates from value back toward its target.
luau
local offset = Flux.spring(0, 3, 0.85)

-- Jump directly to 100 at rest (no animation)
offset:snap(100)

-- Displace the current position to 140 so it springs back toward 100
offset:setCurrent(140)

:impulse(), :setVelocity(), :setCurrent(), and :snap() are spring-only. Calling them on a Tween warns and does nothing.

Lifecycle ​

When created inside a Scope (such as a component), a spring's per-frame step registration is cleaned up automatically when the scope is destroyed. To stop and dispose a spring early, call spring:Destroy().

A Bound Spring Is Not Owned by Its Instance

Flux.new and Flux.edit own a getter function bound to a property (Position = function() ... end), but a spring passed straight to a property (Position = Flux.spring(fn)) only gets a binding. Created outside any owner, a spring with a reactive target, frequency, or damping keeps its driving Effect subscribed to those sources after the instance is destroyed, and re-runs it whenever they change. Create the spring inside a Scope that is destroyed with the instance, or hand it to the instance's __CLEAN:

luau
local _, button = Flux.scope(function(scope)
    return Flux.new "TextButton" {
        Size = Flux.spring(function()
            return if isHovering() then UDim2.fromScale(1.1, 1.1) else UDim2.fromScale(1, 1)
        end, 3, 0.75),
        __CLEAN = { scope }, -- destroying `button` destroys the scope, which owns the spring
    }
end)

Supported Data Types ​

Flux interpolates almost all standard Roblox UI and 3D data types directly, so you never need to split out X, Y, and Z components or calculate alphas manually.

Supported types include:

Passing an unsupported type, such as a boolean, string, or EnumItem, raises an error. The one exception is nil: a motion that starts at nil warns and then follows its target without animating.

Oklab Color Interpolation ​

When animating a Color3, ColorSequence, or ColorSequenceKeypoint, Flux converts colors into the Oklab perceptual color space instead of interpolating linear RGB, avoiding muddy or grayish midpoints.

The same perceptual space is available as a standalone toolkit (lighten, saturate, blend, and check contrast) on the Color page.