Animation and Transitions

Copy Markdown View Source

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!")
end

When 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")
end

Toggle 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)
end

On 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
end

Looping 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

PresetFeel
:gentleSlow, smooth, no overshoot
:snappyQuick, minimal overshoot
:bouncyQuick with visible overshoot
:stiffVery quick, crisp stop
:molassesSlow, 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
end

Reusable 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)
end

This 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
end

The 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!")
end

Try it

  • Try different easing curves on the file list stagger: :ease_out, :ease_in_out, :spring.
  • Add a spring to the preview pane width and toggle it with a button.
  • Experiment with sequence to chain multiple animations on one prop.

Next: Subscriptions