Animating UI Elements
This guide covers two approaches to animating widgets: manual animation in Tick() for simple tweens, and Timeline-based animation for complex, authored sequences.
Manual Animation in Tick
The most direct approach: update widget properties each frame using delta time.
Fading In
function FadeIn:Start()
self.elapsed = 0.0
self.duration = 1.0
self:SetOpacityFloat(0.0) -- start invisible
end
function FadeIn:Tick(deltaTime)
self.elapsed = self.elapsed + deltaTime
local t = math.min(self.elapsed / self.duration, 1.0)
self:SetOpacityFloat(t)
end
C++
void FadeWidget::Tick(float deltaTime)
{
Widget::Tick(deltaTime);
mElapsed += deltaTime;
float t = glm::clamp(mElapsed / mDuration, 0.0f, 1.0f);
SetOpacityFloat(t);
}
Fading Out
function FadeOut:Tick(deltaTime)
self.elapsed = self.elapsed + deltaTime
local t = math.min(self.elapsed / self.duration, 1.0)
self:SetOpacityFloat(1.0 - t)
if t >= 1.0 then
self:SetVisible(false) -- hide when fully faded
end
end
Crossfading Two Images
Overlap two Quads and animate their opacities inversely:
function Crossfade:Start()
-- Two overlapping quads
self.imageA = self:CreateChild("Quad")
self.imageA:SetAnchorMode(AnchorMode.FullStretch)
self.imageA:SetRatios(0.0, 0.0, 1.0, 1.0)
self.imageA:SetTexture(LoadAsset("T_ImageA"))
self.imageA:SetOpacityFloat(1.0)
self.imageB = self:CreateChild("Quad")
self.imageB:SetAnchorMode(AnchorMode.FullStretch)
self.imageB:SetRatios(0.0, 0.0, 1.0, 1.0)
self.imageB:SetTexture(LoadAsset("T_ImageB"))
self.imageB:SetOpacityFloat(0.0)
self.blendT = 0.0
self.blending = false
end
function Crossfade:StartBlend()
self.blendT = 0.0
self.blending = true
end
function Crossfade:Tick(deltaTime)
if not self.blending then return end
self.blendT = self.blendT + deltaTime / 1.0 -- 1 second duration
if self.blendT >= 1.0 then
self.blendT = 1.0
self.blending = false
end
self.imageA:SetOpacityFloat(1.0 - self.blendT)
self.imageB:SetOpacityFloat(self.blendT)
end
Moving a Widget
Slide a widget from one position to another:
function SlideIn:Start()
self.startPos = Vec(-300.0, 100.0) -- off-screen left
self.endPos = Vec(50.0, 100.0) -- on-screen
self.elapsed = 0.0
self.duration = 0.5
self:SetPosition(self.startPos.x, self.startPos.y)
end
function SlideIn:Tick(deltaTime)
self.elapsed = self.elapsed + deltaTime
local t = math.min(self.elapsed / self.duration, 1.0)
-- Ease-out (decelerate)
local eased = 1.0 - (1.0 - t) * (1.0 - t)
local x = self.startPos.x + (self.endPos.x - self.startPos.x) * eased
local y = self.startPos.y + (self.endPos.y - self.startPos.y) * eased
self:SetPosition(x, y)
end
C++
void SlideWidget::Tick(float deltaTime)
{
Widget::Tick(deltaTime);
mElapsed += deltaTime;
float t = glm::clamp(mElapsed / mDuration, 0.0f, 1.0f);
// Ease-out
float eased = 1.0f - (1.0f - t) * (1.0f - t);
glm::vec2 pos = glm::mix(mStartPos, mEndPos, eased);
SetPosition(pos.x, pos.y);
}
Scaling a Widget
Pulse or grow a widget using scale:
function PulseWidget:Start()
self.time = 0.0
end
function PulseWidget:Tick(deltaTime)
self.time = self.time + deltaTime
local scale = 1.0 + 0.1 * math.sin(self.time * 4.0) -- gentle pulse
self:SetScale(scale, scale)
end
Color Transitions
Lerp between two colors:
function ColorFlash:Start()
self.startColor = Vec(1, 1, 1, 1) -- white
self.endColor = Vec(1, 0, 0, 1) -- red
self.elapsed = 0.0
self.duration = 0.3
end
function ColorFlash:Tick(deltaTime)
self.elapsed = self.elapsed + deltaTime
local t = math.min(self.elapsed / self.duration, 1.0)
local r = self.startColor.x + (self.endColor.x - self.startColor.x) * t
local g = self.startColor.y + (self.endColor.y - self.startColor.y) * t
local b = self.startColor.z + (self.endColor.z - self.startColor.z) * t
local a = self.startColor.w + (self.endColor.w - self.startColor.w) * t
self:SetColor(Vec(r, g, b, a))
end
Reusable Tween Helper
A simple Lua helper you can use across your project:
Tween = {}
function Tween.Lerp(a, b, t)
return a + (b - a) * t
end
function Tween.EaseOut(t)
return 1.0 - (1.0 - t) * (1.0 - t)
end
function Tween.EaseIn(t)
return t * t
end
function Tween.EaseInOut(t)
if t < 0.5 then
return 2.0 * t * t
else
return 1.0 - 2.0 * (1.0 - t) * (1.0 - t)
end
end
Usage:
local t = math.min(self.elapsed / self.duration, 1.0)
local eased = Tween.EaseOut(t)
self:SetOpacityFloat(Tween.Lerp(0.0, 1.0, eased))
Timeline-Based Animation
For more complex, designer-driven animations, use the Timeline system. A Timeline asset contains tracks that animate node properties over time, including widget properties like opacity, color, and position.
Using ScriptValueTrack for Widget Properties
A ScriptValueTrack can animate any script property on a node. Attach a script to your widget, expose a property, and animate it from the Timeline.
Example Lua script with animatable property:
function AnimatedPanel:Create()
-- Expose a property the Timeline can drive
self.fadeAmount = 0.0
end
function AnimatedPanel:GatherProperties()
return {
{ name = "fadeAmount", type = "Float" }
}
end
function AnimatedPanel:Tick(deltaTime)
-- Apply the animated value to the widget
self:SetOpacityFloat(self.fadeAmount)
end
Then in the editor, create a Timeline asset with a ScriptValueTrack targeting the fadeAmount property on this node. Add keyframes to define the animation curve.
Playing a Timeline
-- Attach a TimelinePlayer to the scene and play
local player = self:CreateChild("TimelinePlayer")
player:SetTimeline(LoadAsset("TL_UIAnimation"))
player:Play()
For full details on the Timeline system, see the Timeline documentation.
Sprite Animation (frame-by-frame)
For frame-by-frame sprite animations (walk cycles, pickups, FX), the engine ships dedicated nodes that handle playback, frame timing, and atlas UV math without any of the manual Tick-based plumbing above.
AnimatedWidget — sprite animation in UI
Drop in an AnimatedWidget (it appears in Add Widget under Canvas), assign one or more SpriteAnimation assets to its Animations array, set Default Animation, tick Auto Play, and hit Play. No script required for basic playback.
-- Minimal control from script
self.widget:PlayAnimation("walk")
self.widget:Pause()
self.widget:SetFrame(0)
Discrete vs Atlas frames
Each SpriteAnimation asset operates in one of two modes:
- Discrete — one Texture per frame. Best when frames come from individual PNGs.
- AtlasGrid — one packed sprite-sheet texture sliced into a grid. Best for memory efficiency. Cells are addressed by index in the playback order list. Comes with a visual editor (Edit Atlas Frames… button) for clicking cells to add to the playback.
Both modes use the same playback API on the animator nodes — switching modes on an asset changes how frames are stored and sampled, but doesn't change how scripts drive playback.
Other sprite nodes
- SpriteAnimator — logical-only animator (no rendering). Useful when you want to feed multiple Quads or custom shader params from one timeline.
- AnimatedSprite3D — drives a 3D Material's texture parameters (Diffuse / Alpha / Emission). Place next to a StaticMesh3D using the same Material.
Targeted playback
The animators support AnimateTo(frame, pauseOnFinished, onFinished) and AnimateToProgress(0..1, ...) for "play to here, then notify" patterns:
-- Wind-up, attack on peak frame, return to idle
self.spr:PlayAnimation("attack")
self.spr:AnimateTo(8, false, function()
Player:DealDamage()
end)
GetProgress() returns smooth 0..1 playback position, useful for driving progress bars or cross-fades synced to animation completion.
Further Reading
- AnimatedWidget UI guide
- SpriteAnimator (Lua)
- AnimatedSprite3D (Lua)
- SpriteAnimation asset (Lua)
- Widget System Overview
- Displaying Images — animate Quad opacity, UV offset
- Building Complete UIs — full examples with animation
- Timeline Overview