Inputs and Data Binding
Learning Objectives
- Define and use script inputs with
Input<T> - Read Input values directly (e.g.,
self.size) - Respond to input changes with
update() - Connect to ViewModels for complex data binding
AE/JS Syntax Comparison
| Concept | After Effects / JavaScript | Luau (Rive Scripts) |
|---|---|---|
| Editor control | Effect Control (Slider, Checkbox) | Input<number>, Input<boolean> |
| Read control value | effect("Slider Control")(1) | self.slider |
| Watch for changes | posterizeTime() or manual check | update(self) callback |
| Default value | Set in Effect Controls Panel | Set in factory return table |
| Property binding | Link expression to property | context:viewModel() |
| Read-only input | N/A (AE controls are always editable) | Input<T> (read-only by convention, not runtime-enforced) |
| Two-way binding | Not native (requires workarounds) | ViewModel .value setter |
In this version of Rive scripting, inputs are read directly (e.g., self.size). The editor still treats them as inputs, but you do not access a .value field.
Don't confuse these two access patterns:
Editor Inputs (Input<T>) — Read directly, no .value:
local size = self.width -- Direct access
ViewModel Properties (Property<T>) — Use .value to read/write:
local score = vm:getNumber("score")
if score then
print(score.value) -- Read with .value
score.value = 100 -- Write with .value
end
The key difference: Inputs come from the editor inspector; Properties come from ViewModels.
Rive Context: Inputs Are Your Editor Controls
Script inputs are how designers control your script from the Rive UI. You define them in the script type and provide defaults in the factory return table.
Key rules:
- Inputs are intended to be read-only in scripts (read them directly, don't reassign)
- While not runtime-enforced, writing to inputs bypasses the editor binding system
- Use
update(self)to respond to changes - Use
late()ornilfor inputs that are assigned in the editor (ViewModel or Artboard inputs)
Input Definition Pattern
--!strict
export type MyNode = {
size: Input<number>,
tint: Input<Color>,
}
return function(): Node<MyNode>
return {
init = init,
draw = draw,
size = 80, -- Default for size input
tint = Color.rgb(255, 80, 120), -- Default for color input
}
end
// JavaScript/TypeScript mental model:
// Inputs are like React props with default values
interface Props {
size: number; // Default: 80
tint: Color; // Default: rgb(255, 80, 120)
}
class MyComponent {
props: Props = {
size: 80,
tint: { r: 255, g: 80, b: 120 }
};
}
When you define an Input<T> type in your script, Rive automatically creates a control in the inspector panel. The factory return value provides the default.
Supported Input Types
| Type | Example Default | Description | AE Equivalent |
|---|---|---|---|
Input<number> | 42 | Numeric slider/field | Slider Control |
Input<boolean> | true | Checkbox | Checkbox Control |
Input<string> | "hello" | Text field | N/A |
Input<Color> | Color.rgb(255, 0, 0) | Color picker | Color Control |
Input<Data.X> | late() | ViewModel reference | N/A |
Practice Exercises
Exercise 1: Size + Color Inputs ⭐⭐
Premise
The update() function is called only when inputs change. This is more efficient than rebuilding every frame in advance()—you only do work when the data actually changes.
By the end of this exercise, you will be able to Complete the rebuild function to build a square Path based on the size input. The path should be centered at the origin with corners at (-half, -half) to (half, half).
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1SizeColorInputs
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A square that rebuilds when size input changes
export type InputSquare = {
size: Input<number>,
path: Path,
paint: Paint,
}
local function rebuild(self: InputSquare)
-- TODO 1: Calculate half as size divided by 2
local half = 0
-- TODO 2: Reset the path and build a square
-- Use self.path:reset() first
-- Then moveTo(-half, -half), lineTo corners, and close()
print(`Rebuilt: size={self.size}`)
print("ANSWER: rebuilt")
end
function init(self: InputSquare): boolean
print("Creating input-driven square...")
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(90, 180, 255) })
rebuild(self)
return true
end
function update(self: InputSquare)
rebuild(self)
end
function draw(self: InputSquare, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<InputSquare>
return {
init = init,
update = update,
draw = draw,
size = 80,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the rebuild function to build a square path based on the size input. The path should be centered at the origin with corners at (-half, -half) to (half, half).
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 2: Time-Based Animation ⭐⭐
Premise
Time-based calculations with sine/cosine create smooth oscillations. This pattern is the foundation for procedural animation—moving elements based on mathematical functions rather than keyframes.
By the end of this exercise, you will be able to Complete the advance function to track time and calculate an oscillating position using sine. The position should swing between -60 and +60.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2TimeBasedAnimation
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Animate a position over time using sine wave
export type AnimationDriver = {
speed: number,
amplitude: number,
time: number,
position: number,
}
function init(self: AnimationDriver): boolean
print("Driver initialized")
self.time = 0
self.position = 0
return true
end
function advance(self: AnimationDriver, seconds: number): boolean
-- TODO 1: Add seconds to self.time
-- TODO 2: Calculate position using sine wave
-- Formula: math.sin(self.time * self.speed) * self.amplitude
-- Log progress (every ~30 frames)
if math.floor(self.time * 60) % 30 == 0 and self.time > 0.1 then
print(`Time: {string.format("%.1f", self.time)}s, Position: {string.format("%.1f", self.position)}`)
end
-- Print answer after 2 seconds
if self.time >= 2 and self.time < 2.02 then
print("ANSWER: oscillating")
end
return true
end
function draw(self: AnimationDriver, renderer: Renderer)
end
return function(): Node<AnimationDriver>
return {
init = init,
advance = advance,
draw = draw,
speed = 3,
amplitude = 60,
time = 0,
position = 0,
}
end
Assignment
Complete these tasks:
- Complete the advance function to track time and calculate an oscillating position using sine. The position should swing between -60 and +60.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 3: Input Change Counter ⭐⭐
Premise
The update() function is called whenever inputs change. By tracking previous values, you can detect and count changes—useful for analytics, undo systems, or triggering one-time effects.
By the end of this exercise, you will be able to Complete the update function to track how many times the speed input has changed. Store the previous value and compare it to detect changes.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3InputChangeCounter
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Track how many times an input has changed
export type InputTracker = {
speed: Input<number>,
previousSpeed: number,
changeCount: number,
}
function init(self: InputTracker): boolean
print("Input tracker ready")
self.previousSpeed = self.speed
self.changeCount = 0
return true
end
function update(self: InputTracker)
-- TODO 1: Check if speed has changed from previousSpeed
-- TODO 2: If changed:
-- - Increment changeCount
-- - Print the change: "Speed changed: {old} -> {new} (change #{count})"
-- - Update previousSpeed to current value
-- TODO 3: If changeCount reaches 3, print "ANSWER: 3"
end
function draw(self: InputTracker, renderer: Renderer)
end
return function(): Node<InputTracker>
return {
init = init,
update = update,
draw = draw,
speed = 2,
previousSpeed = 0,
changeCount = 0,
}
end
Assignment
Complete these tasks:
- Complete the update function to track how many times the speed input has changed. Store the previous value and compare it to detect changes.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
When to Use Each Approach
| Approach | Use When | AE/JS Mental Model |
|---|---|---|
Script Inputs (Input<T>) | Per-instance configuration (each script instance can have different values) | Component props |
context:viewModel() | Global data shared across all scripts | Redux store / React Context |
late() | Editor-assigned references (ViewModels, other nodes) | Dependency injection |
addListener() | Reactive updates to external changes | Event subscription / Observable |
Input<T> vs ViewModel: When to Use Which
This is a common source of confusion. Here's how to think about it:
The Core Difference
Input<T> | ViewModel Property | |
|---|---|---|
| Scope | Per-instance (each script can have different values) | Global (shared across all scripts) |
| Set from | Rive editor inspector panel | Runtime API, external code, or bindings |
| Direction | Read-only in scripts | Read/write in scripts |
| Changes trigger | update(self) callback | Listeners you register |
Mental Model: Props vs State
Think of it like React:
-- Input<T> = Props (passed to component, read-only)
export type Button = {
label: Input<string>, -- Configured per-instance in editor
width: Input<number>, -- Each button can have different width
}
-- ViewModel = Global State (like Redux/Context)
-- Multiple scripts can read/write the same score
function init(self: GameUI, context: Context): boolean
local vm = context:viewModel()
local score = vm:getNumber("score") -- Shared across all scripts
score.value = score.value + 100 -- Can write to it!
return true
end
Update Flow Comparison
Input<T> Update Flow:
ViewModel Property Update Flow:
Choosing the Right One
Use Input<T> when:
- Designer needs to tweak values per-instance in the editor
- Value is static configuration (size, color, speed)
- Different instances need different values
- No runtime changes needed
Use ViewModel when:
- Value changes at runtime (score, health, progress)
- Multiple scripts need to share data
- External code needs to update values
- Two-way binding with UI elements
Example: Health Bar
export type HealthBar = {
-- Input: Configuration that designers set per-instance
barWidth: Input<number>, -- Each health bar can be different size
barHeight: Input<number>,
-- ViewModel: Runtime data shared globally
context: Context,
}
function init(self: HealthBar, context: Context): boolean
self.context = context
-- Get shared player health from ViewModel
local vm = context:viewModel()
local health = vm:getNumber("playerHealth")
-- Listen for changes (not automatic like update())
if health then
health:addListener(function()
rebuild(self) -- Redraw when health changes
self.context:markNeedsUpdate()
end)
end
return true
end
function update(self: HealthBar)
-- Called when barWidth or barHeight changes
rebuild(self)
end
Exercise 4: Progress Bar with Inputs ⭐⭐
Premise
Progress bars combine multiple inputs (width, height, current, max) to create a visual representation of data. This pattern is used for health bars, loading indicators, and any value-to-visual mapping.
By the end of this exercise, you will be able to Complete the rebuild function to draw a progress bar. Calculate the filled width based on current/max ratio, and build paths for both background and foreground.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4ProgressBarWithInputs
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A progress bar driven by inputs
export type ProgressBar = {
barWidth: Input<number>,
barHeight: Input<number>,
current: Input<number>,
max: Input<number>,
bgPath: Path,
fillPath: Path,
bgPaint: Paint,
fillPaint: Paint,
}
local function rebuild(self: ProgressBar)
-- TODO 1: Calculate the percentage (current / max)
local percent = 0
-- TODO 2: Calculate filledWidth (percent * barWidth)
local filledWidth = 0
-- TODO 3: Build bgPath as full-width rectangle (0,0 to barWidth,barHeight)
self.bgPath:reset()
-- Add moveTo, lineTo, lineTo, lineTo, close
-- TODO 4: Build fillPath as partial-width rectangle (0,0 to filledWidth,barHeight)
self.fillPath:reset()
-- Add moveTo, lineTo, lineTo, lineTo, close
-- Print status
print(`Current: {self.current} / Max: {self.max}`)
print(`Fill percentage: {math.floor(percent * 100)}%`)
print(`ANSWER: {math.floor(percent * 100)}%`)
end
function init(self: ProgressBar): boolean
print("Progress bar initialized")
self.bgPath = Path.new()
self.fillPath = Path.new()
self.bgPaint = Paint.with({ style = "fill", color = Color.rgb(60, 60, 60) })
self.fillPaint = Paint.with({ style = "fill", color = Color.rgb(80, 200, 120) })
rebuild(self)
return true
end
function update(self: ProgressBar)
rebuild(self)
end
function draw(self: ProgressBar, renderer: Renderer)
renderer:drawPath(self.bgPath, self.bgPaint)
renderer:drawPath(self.fillPath, self.fillPaint)
end
return function(): Node<ProgressBar>
return {
init = init,
update = update,
draw = draw,
barWidth = 200,
barHeight = 20,
current = 75,
max = 100,
bgPath = late(),
fillPath = late(),
bgPaint = late(),
fillPaint = late(),
}
end
Assignment
Complete these tasks:
- Complete the rebuild function to draw a progress bar. Calculate the filled width based on current/max ratio, and build paths for both background and foreground.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Knowledge Check
Common Mistakes
1. Using .value on Inputs
-- WRONG: .value does not exist on inputs here
local half = self.size.value / 2
-- CORRECT: Read the input directly
local half = self.size / 2
2. ViewModel Inputs: late() vs nil
-- Both work at runtime:
return function(): Node<MyNode>
return {
character = nil, -- Works!
-- OR
character = late(), -- Also works (recommended for type safety)
}
end
Note: late() is recommended for type safety in --!strict mode, but nil works identically at runtime.
3. Reassigning Input Values in Scripts
-- NOT RECOMMENDED: Inputs are intended to be read-only
function advance(self: MyNode, seconds: number): boolean
self.size = 100 -- Works but bypasses editor binding
return true
end
-- CORRECT: Read Input values, write to regular properties or ViewModel
function advance(self: MyNode, seconds: number): boolean
local currentSize = self.size -- Read
self.computedSize = currentSize * 2 -- Write to regular property
return true
end
Note: While reassigning Input values doesn't cause a runtime error, it bypasses the editor's data binding system. Always use ViewModel properties for two-way binding.
4. Rebuilding in advance() Instead of update()
-- WRONG: Rebuilding every frame even when inputs haven't changed
function advance(self: MyNode, seconds: number): boolean
rebuild(self) -- Wasteful!
return true
end
-- CORRECT: Rebuild only when inputs change
function update(self: MyNode)
rebuild(self) -- Called only when inputs change
end
Rive Takeaway
Use inputs for per-instance tuning (each script instance can have different values) and use context:viewModel() for global data shared across all scripts in the artboard.
Next Steps
- Continue to Node Protocol
- Need a refresher? Review Quick Reference