Your widgets are styled. Now make them move. Animation turns a functional UI into one that feels responsive and alive. Elements that slide, fade, and spring give the user feedback that the interface is reacting to their actions.
Plushie's animation system is built around a key insight: the
renderer is closer to the screen than your Elixir code. By
declaring animation intent in view/1 and letting the renderer
handle interpolation, you get smooth 60fps animation with zero
wire traffic. No subscriptions, no frame events, no model state.
Transitions
A transition animates a numeric prop from its current value to a target. Declare it inline:
container "box", max_width: transition(300, to: 200) do
text("hello", "Hello!")
endWhen the target changes (say the model toggles a boolean), the renderer smoothly interpolates from the current value to the new target. One descriptor over the wire, then silence while the renderer handles the rest.
container "panel",
max_width: transition(300, to: if(model.expanded, do: 400, else: 200)) do
text("content", "Expandable panel")
endToggle model.expanded in your update/2 and the panel width
animates smoothly.
Options
# Easing (default: :ease_in_out)
max_width: transition(300, to: 200, easing: :ease_out)
# Delay before starting
max_width: transition(300, to: 200, delay: 100)
# Completion event
max_width: transition(300, to: 200, on_complete: :panel_resized)See Plushie.Animation.Easing for the full catalogue of 31
easing curves plus cubic bezier support.
Enter animations
Use from: to animate a prop when a widget first appears:
container "item",
max_width: transition(200, to: 300, from: 0) do
text("name", item.name)
endOn mount, the renderer starts at from: 0 and animates to
to: 300. On subsequent renders, from: is ignored and the
animation runs from the current value.
Staggered enter
Delay each item in a list for a cascade effect:
for {item, i} <- Enum.with_index(model.items) do
container item.id,
max_width: transition(200, to: 300, from: 0, delay: i * 50) do
text(item.id <> "-name", item.name)
end
endLooping animations
loop cycles between two values:
# Pulse forever
max_width: loop(800, to: 250, from: 300)
# Finite: 3 cycles
max_width: loop(800, to: 250, from: 300, cycles: 3)By default, loops auto-reverse (ping-pong). For continuous forward motion (like a spinner rotation):
rotation: loop(1000, to: 360, from: 0, auto_reverse: false)Springs
Springs use physics simulation instead of timed curves. They have no fixed duration. They settle naturally based on stiffness and damping:
# Using a preset
scale: spring(to: 1.05, preset: :bouncy)
# Custom parameters
scale: spring(to: 1.05, stiffness: 200, damping: 20)Springs handle interruption gracefully. If the target changes mid-animation, the spring preserves velocity and smoothly redirects. This makes them ideal for interactive elements.
Presets
| Preset | Feel |
|---|---|
:gentle | Slow, smooth, no overshoot |
:snappy | Quick, minimal overshoot |
:bouncy | Quick with visible overshoot |
:stiff | Very quick, crisp stop |
:molasses | Slow, heavy, deliberate |
Sequences
Chain animations that play one after another:
max_width: sequence([
transition(200, to: 300, from: 0),
loop(800, to: 250, from: 300, cycles: 3),
transition(300, to: 0)
])Each step's starting value defaults to the previous step's ending value.
Three DSL forms
Like all Plushie DSL, transitions support keyword, pipeline, and do-block forms:
# Keyword
max_width: transition(300, to: 200, easing: :ease_out)
# Pipeline
alias Plushie.Animation.Transition
max_width: Transition.new(300, to: 200) |> Transition.easing(:ease_out)
# Do-block
max_width: transition 300 do
to 200
easing :ease_out
endReusable helpers
Define common animation patterns as private functions:
defp fade_in(to, delay \\ 0) do
transition(200, to: to, from: 0, easing: :ease_out, delay: delay)
end
defp slide_in(to, delay \\ 0) do
transition(300, to: to, from: 20, easing: :ease_out, delay: delay)
endThis is plain Elixir, no framework feature, just functions returning structs.
SDK-side animation
For complex cases that need frame-by-frame control (canvas
animations, physics simulations, chained model updates), use
Plushie.Animation.Tween:
anim = Tween.new(from: 0.0, to: 1.0, duration: 300, easing: :ease_out)This requires a subscription for frame events (covered in the
next chapter) and manual advancement in
update/2. See Plushie.Animation.Tween and the
animation reference for details.
For most property animations, renderer-side transitions are simpler and more performant.
Applying it: animated pad
Add a staggered entrance to the file list. Each file fades in with a slight delay, creating a cascade effect when the pad starts or new files are created:
# In file_list, wrap each file entry:
for {file, i} <- Enum.with_index(model.files) do
container file, opacity: transition(200, to: 1.0, from: 0.0, delay: i * 40) do
row spacing: 4 do
button("select", file,
width: :fill,
style: if(file == model.active_file, do: :primary, else: :text)
)
button("delete", "x")
end
end
endThe from: 0.0 makes each item start fully transparent and fade to
1.0. The delay: i * 40 offsets each item by 40ms, so they appear
one after another. When a new file is created, it enters the tree for
the first time and gets its own entrance animation. Existing files
that are already in the tree are unaffected. from: only applies
on first appearance.
Verify it
Animations resolve to their target values in mock mode, so tests work without waiting for frames:
test "file list renders with animation descriptors" do
assert_exists("#hello.ex/select")
click("#save")
assert_text("#preview/greeting", "Hello, Plushie!")
endTry it
- Try different easing curves on the file list stagger:
:ease_out,:ease_in_out,:spring. - Add a
springto the preview pane width and toggle it with a button. - Experiment with
sequenceto chain multiple animations on one prop.
Next: Subscriptions