Plushie's animation system is built around a key insight: your view is a pure function. You can't call element.animate() because there's no element to call it on. Instead, animations are declarative prop values. You describe what each prop should be, and the renderer interpolates from where it is to where you want it. Zero wire traffic, zero subscriptions, zero model state.

This reference covers the full animation system: how it works, what you can animate, how to choose the right animation, and how to build fluid, responsive UIs that feel alive.

For a tutorial introduction, see guide chapter 9.

How animation works

The declarative model

In your view/1, wrap a prop value in a transition(), spring(), or loop() descriptor:

container "panel", max_width: transition(300, to: 200, easing: :ease_out) do
  text("content", "Hello")
end

The renderer receives this descriptor and interpolates max_width from its current value to 200 over 300ms. Your Elixir code does nothing during the animation. No subscriptions, no frame events, no model updates. The renderer runs the interpolation locally at full frame rate.

The from/to lifecycle

This is the core concept:

  • to: is always required. It's the target value, where the prop should end up.
  • from: only applies on first appearance. When a widget enters the tree for the first time, the renderer starts at from: and animates to to:. On subsequent renders, from: is ignored.
  • When to: changes between renders, the renderer starts a new animation from the current interpolated value to the new target. No jump, no restart from the beginning.
  • When to: stays the same, nothing happens. The prop stays at its target. Returning the same descriptor every render is free.

This means your view function doesn't need to know about animation state. It just describes the desired state:

# In view/1:
container "sidebar",
  max_width: transition(300, to: if(model.sidebar_open, do: 250, else: 0)) do
  sidebar_content(model)
end

# In update/2:
def update(model, %WidgetEvent{type: :click, id: "toggle-sidebar"}) do
  %{model | sidebar_open: not model.sidebar_open}
end

Toggle sidebar_open and the sidebar animates. Toggle again mid-animation and it smoothly reverses from wherever it currently is. The view function is identical every time; only the model changes.

Zero wire traffic

Renderer-side animations send nothing over the wire while running. The descriptor crosses the wire once (in the snapshot or patch), then the renderer handles all interpolation locally. This makes them ideal for remote rendering over SSH or high-latency connections. Animations run at full frame rate regardless of network conditions.

Animation principles

Purpose-driven animation

Every animation should communicate something:

PurposeExampleRecommended
EnterContent appearsFade in, slide in
ExitContent disappearsFade out, slide out, collapse
FeedbackUser action acknowledgedScale pulse, color flash
AttentionSomething needs actionGentle pulse, bounce
State changeValue updatedSmooth transition between states
ProgressWork in progressLoading shimmer, spinner

If an animation doesn't serve one of these purposes, consider removing it. Gratuitous animation is worse than no animation.

Timing guidelines

ContextDurationNotes
Micro-interactions (hover, toggle)100-150msFast enough to feel instant
Standard transitions (fade, slide)200-300msThe sweet spot for most UI
Complex transitions (page change)300-500msSlower for large movements
Enter animations200-300msUsers wait for content to appear
Exit animations150-200msSlightly faster; dismissed content should leave quickly
Stagger delay per item30-50msToo fast looks simultaneous, too slow looks broken

Under 100ms is effectively instant, so you might as well skip the animation. Over 500ms feels sluggish. For direct manipulation (dragging, resizing), use no animation at all because the response should feel 1:1 connected to the input. Animation is for the response after the interaction.

Animatable props

Not every prop supports animation descriptors; the renderer must know how to interpolate between values. These props are animatable:

Numeric props

PropWidgetsPurpose
opacityMost widgetsFade effects (0.0 transparent, 1.0 opaque)
max_widthcolumn, row, containerExpand/collapse width
max_heightcontainerExpand/collapse height
spacingcolumn, rowAnimate gaps between children
scaleMost widgetsGrow/shrink effect
rotationtext, rich_text, imageRotate in degrees
border_radiusimage, text, rich_textAnimate corner rounding
sizetext, rich_textAnimate text size
translate_xfloatingHorizontal slide
translate_yfloatingVertical slide
xpinHorizontal position
ypinVertical position
widthsliderSlider track width
heighttext_editorEditor height
valueprogress_barSmooth progress changes
text_size, h1_size, h2_size, h3_size, code_sizemarkdownHeading/code size animation

Color props (planned)

The renderer has Oklch colour interpolation implemented (perceptually uniform, shortest-hue-arc), but colour prop animation is not yet wired to specific widgets. When enabled, colour transitions will produce smooth, vibrant interpolation without the muddy midpoints of RGB blending.

Props that are NOT animatable

Width/height as Length values (:fill, :shrink, {:fill_portion, n}) cannot be animated because they're layout directives, not numbers. Use max_width/max_height for size animation. Padding sides are not individually animatable. Boolean props (visible, clip, disabled) snap immediately.

Transitions

Plushie.Animation.Transition

Timed animations with a fixed duration and easing curve. Predictable and coordinated. You know exactly when they'll finish.

transition(300, to: 200)
transition(300, to: 200, easing: :ease_out, delay: 100)
transition(to: 200, duration: 300)  # all keyword
OptionTypeDefaultDescription
to:numberrequiredTarget value
duration:pos_integerrequiredDuration in milliseconds
easing:easing atom:ease_in_outEasing curve (see catalogue)
delay:non_neg_integer0Delay before start
from:numbernilExplicit start value (enter animations only)
repeat:integer / :forevernilRepeat count
auto_reverse:booleanfalseReverse direction on each repeat
on_complete:atomnilEmit completion event

Looping

loop() is sugar for repeating transitions. Sets repeat: :forever and auto_reverse: true by default. Requires from: since the animation needs a cycle range.

# Pulse forever
opacity: loop(1500, to: 1.0, from: 0.7)

# Finite cycles
opacity: loop(800, to: 1.0, from: 0.5, cycles: 3)

# One direction only (spinner rotation)
rotation: loop(1000, to: 360, from: 0, auto_reverse: false)

Springs

Plushie.Animation.Spring

Physics-based animations with no fixed duration. A spring pulls toward the target with a force proportional to the distance, opposed by friction (damping). It settles naturally when the velocity and displacement are both near zero.

spring(to: 1.05, preset: :bouncy)
spring(to: 200, stiffness: 200, damping: 20)
OptionTypeDefaultDescription
to:numberrequiredTarget value
from:numbernilExplicit start value
stiffness:number100How hard the spring pulls (higher = faster)
damping:number10Friction (higher = less bounce)
mass:number1.0Inertia (higher = slower to start/stop)
velocity:number0.0Initial velocity
preset:atomnilNamed parameter set
on_complete:atomnilEmit completion event

Presets

PresetStiffnessDampingFeel
:gentle12014Slow, smooth, no overshoot
:snappy20020Quick, minimal overshoot
:bouncy30010Quick with visible bounce
:stiff40030Very quick, crisp stop
:molasses6012Slow, heavy, deliberate

Tuning by feel

Think of it physically: stiffness is how hard the spring pulls toward the target. Damping is friction that slows it down. Mass is how heavy the object is.

  • More stiffness = faster, snappier
  • More damping = less bounce, more controlled
  • More mass = slower to start and stop, more momentum
  • High stiffness + low damping = bouncy (like a rubber ball)
  • Low stiffness + high damping = sluggish (like moving through honey)

Sequences

Plushie.Animation.Sequence

Chain animations that play one after another on the same prop:

max_width: sequence([
  transition(200, to: 300, from: 0),
  transition(500, to: 300),           # hold at 300 for 500ms
  transition(300, to: 0)              # collapse
])

Each step's starting value defaults to the previous step's ending value. You can mix transitions and springs in a sequence. Useful for multi-phase animations: enter, hold, exit.

Choosing your animation

Transition vs spring

Use caseChooseWhy
Choreographed timing (page transitions, stagger)TransitionFixed duration, predictable coordination
Interactive feedback (hover, toggle, drag release)SpringHandles interruption gracefully, velocity carries
Entrance/exitTransitionConsistent timing regardless of distance
Bouncy/playfulSpring with :bouncyNatural overshoot without manual keyframes
Loading/progressTransition with linearPredictable rate
Subtle polish (micro-interactions)Spring with :snappyFast, natural, barely noticeable

Easing guidance

Not just what they do, but when to use them:

  • :ease_out - use for things appearing. They decelerate into their resting position, like a ball rolling to a stop. The most common easing for enter animations.
  • :ease_in - use for things disappearing. They accelerate away, like an object falling. Rarely used alone.
  • :ease_in_out - use for things moving within the UI (panel slide, tab switch). Smooth start and stop. The default for a reason.
  • :linear - use for continuous motion (progress bars, loading spinners, color cycling). No acceleration feels mechanical and deliberate.
  • :ease_out_back - use for playful entrances. Overshoots slightly then settles, adding personality.
  • :ease_out_elastic - use for attention-grabbing effects. Oscillates like a struck spring. Use sparingly.
  • :ease_out_bounce - use for physics-like settling. Like dropping a ball. Fun but distracting if overused.
  • :cubic_bezier - use when none of the 31 presets match. Control points match the CSS cubic-bezier() function.

Easing catalogue

31 named curves plus custom cubic bezier. Pass as atoms to easing:.

Standard

EasingFeel
:linearConstant velocity, mechanical
:ease_inGentle acceleration (sine)
:ease_outGentle deceleration (sine)
:ease_in_outSmooth both ends (sine, default)

Power curves

Increasing intensity from quad to quint:

FamilyInOutIn-out
Quadratic:ease_in_quad:ease_out_quad:ease_in_out_quad
Cubic:ease_in_cubic:ease_out_cubic:ease_in_out_cubic
Quartic:ease_in_quart:ease_out_quart:ease_in_out_quart
Quintic:ease_in_quint:ease_out_quint:ease_in_out_quint

Exponential and circular

FamilyInOutIn-out
Exponential:ease_in_expo:ease_out_expo:ease_in_out_expo
Circular:ease_in_circ:ease_out_circ:ease_in_out_circ

Overshoot

FamilyInOutIn-out
Back:ease_in_back:ease_out_back:ease_in_out_back
Elastic:ease_in_elastic:ease_out_elastic:ease_in_out_elastic
Bounce:ease_in_bounce:ease_out_bounce:ease_in_out_bounce

Custom cubic bezier

easing: {:cubic_bezier, 0.25, 0.1, 0.25, 1.0}

Control points match the CSS cubic-bezier() function. Use cubic-bezier.com to design curves visually.

Exit animations

The exit: prop defines animations that play when a widget is removed from the tree:

container item.id,
  opacity: transition(200, to: 1.0, from: 0.0),
  exit: [opacity: transition(200, to: 0.0)] do
  text(item.id <> "-name", item.name)
end

When the widget leaves the tree, the renderer keeps it visible as a ghost in layout flow and plays the exit animations. Other widgets don't collapse into the space until the exit completes. When all exit transitions finish, the ghost is removed.

Combine enter (from:) and exit (exit:) for full lifecycle animation: the widget fades in when it appears, fades out when it disappears.

Multiple exit props are supported:

exit: [
  opacity: transition(200, to: 0.0),
  max_width: transition(300, to: 0, easing: :ease_in)
]

Interruption

Target changes

When to: changes mid-animation, the renderer starts a new animation from the current interpolated value. There's no jump, no restart from zero. The animation seamlessly redirects.

For springs, velocity is preserved. If the sidebar is moving right at high speed and you reverse it, the spring carries the momentum into the new direction. This is why springs feel natural for interactive elements because they respect physics.

Snapping

A raw value (no transition() or spring() wrapper) cancels any active animation and snaps to the value immediately:

# Animated:
max_width: transition(300, to: 200)

# Snap (cancels animation):
max_width: 200

Use this when you need instant state changes (e.g., resetting layout on window resize).

Coordinating animations

Synchronised timing

Give two widgets the same duration and easing to make them move together:

container "sidebar", max_width: transition(300, to: sidebar_width, easing: :ease_in_out) do
  ...
end
container "content", max_width: transition(300, to: content_width, easing: :ease_in_out) do
  ...
end

Stagger

Offset each item's delay by its index:

for {item, i} <- Enum.with_index(model.items) do
  container item.id,
    opacity: transition(200, to: 1.0, from: 0.0, delay: i * 40) do
    text(item.id <> "-name", item.name)
  end
end

30-50ms per item produces a cascade. Faster looks simultaneous; slower looks broken.

Completion chaining

Use on_complete: to trigger the next phase from update/2:

max_width: transition(300, to: 0, on_complete: :collapsed)

def update(model, %WidgetEvent{type: :transition_complete, data: %{tag: :collapsed}}) do
  %{model | show_panel: false}  # remove from tree after collapse
end

Reusable animation helpers

Define functions that return descriptors for consistent animation across your app:

defmodule MyApp.Motion do
  def fade_in(delay \\ 0) do
    transition(200, to: 1.0, from: 0.0, easing: :ease_out, delay: delay)
  end

  def slide_down(delay \\ 0) do
    transition(250, to: 0, from: -20, easing: :ease_out, delay: delay)
  end

  def press_scale do
    spring(to: 0.95, preset: :stiff)
  end
end

# Usage in view:
container item.id,
  opacity: Motion.fade_in(i * 40),
  translate_y: Motion.slide_down(i * 40) do
  ...
end

This is plain Elixir, not a framework feature. Just functions returning structs. It creates a consistent motion language for your application.

Accessibility

When the OS reports prefers-reduced-motion, the renderer treats all transition descriptors as instant. Props snap to their target values, exit ghosts are removed immediately. This is automatic; no code changes needed.

This matters because some users have vestibular disorders where large-scale motion causes physical discomfort. Reduced motion doesn't mean no visual feedback; fades and colour changes are generally fine. The issue is large spatial movements (slides, bounces, elastic effects).

Performance

Renderer-side transitions (transition, spring, loop, sequence) cost nothing on the Elixir side. The descriptor crosses the wire once, then the renderer handles all frame-by-frame interpolation locally. No update cycles, no subscriptions, no model state. Animating 100 items with staggered transitions is free from Elixir's perspective.

SDK-side Tween requires on_animation_frame subscription (~60 events/sec), an update cycle per frame, a view/1 call, a tree diff, and a patch. This is real work. Use Tween only when you need the animated value in your model (canvas drawing, animation-driven logic).

SDK-side animation (Tween)

Plushie.Animation.Tween

For cases where you need frame-by-frame control in Elixir (canvas animations, physics simulations, values that drive model logic). The animated value lives in your model and you advance it manually.

alias Plushie.Animation.Tween

# In init:
anim = Tween.new(from: 0.0, to: 1.0, duration: 300, easing: :ease_out)
%{anim: anim, animating: true}

# Subscribe to frame events:
def subscribe(model) do
  if model.animating do
    [Plushie.Subscription.on_animation_frame(:frame)]
  else
    []
  end
end

# Advance on each frame:
def update(model, %SystemEvent{type: :animation_frame, data: ts}) do
  anim = model.anim |> Tween.start_once(ts) |> Tween.advance(ts)

  if Tween.finished?(anim) do
    %{model | anim: nil, animating: false}
  else
    %{model | anim: anim}
  end
end

# Read the value in view:
def view(model) do
  opacity = if model.anim, do: Tween.value(model.anim), else: 1.0
  ...
end

Key functions: new/1, spring/1, start/2, start_once/2, advance/2, redirect/2, value/1, finished?/1, running?/1.

Canvas animation

Canvas shapes are data inside prop values, not individual widgets, so they can't use renderer-side transition descriptors. Use Tween with on_animation_frame to animate canvas content:

canvas "gauge", width: 120, height: 70 do
  layer "needle" do
    angle = Tween.value(model.gauge_anim) * :math.pi()
    path([arc(60, 60, 50, :math.pi(), :math.pi() + angle)],
      stroke: stroke("#3b82f6", 4, cap: :round)
    )
  end
end

Testing

Mock mode resolves transition descriptors to their target value instantly. Tests work without animation awareness; assert_text and assert_exists see the final state.

Headless mode runs real interpolation. Use advance_frame/1 to step through frames deterministically:

@tag backend: :headless
test "panel animates width" do
  click("#toggle")
  advance_frame(150)
  assert find!("#panel").props[:max_width] < 400
  advance_frame(300)
  assert find!("#panel").props[:max_width] == 200
end

skip_transitions/0 fast-forwards all in-flight transitions to completion in one call:

click("#toggle")
skip_transitions()
assert find!("#panel").props[:max_width] == 200

Examples

container "sidebar",
  max_width: transition(250, to: if(model.sidebar_open, do: 250, else: 0),
    easing: :ease_in_out),
  opacity: transition(200, to: if(model.sidebar_open, do: 1.0, else: 0.0)) do
  column padding: 16, spacing: 8 do
    for item <- model.nav_items do
      button(item.id, item.label, width: :fill, style: :text)
    end
  end
end

Notification slide-in

container toast.id,
  opacity: transition(200, to: 1.0, from: 0.0),
  translate_y: spring(to: 0, from: -30, preset: :snappy),
  exit: [opacity: transition(150, to: 0.0)] do
  text(toast.id <> "-msg", toast.text)
end

Skeleton loading shimmer

container "skeleton", background: "#f0f0f0", border_radius: 4 do
  container width: :fill, height: 16,
    opacity: loop(1200, to: 0.4, from: 1.0, easing: :ease_in_out) do
  end
end

Progress bar

progress_bar("upload", {0, 100},
  value: transition(300, to: model.upload_progress, easing: :ease_out)
)

Hover scale with spring

# Track hover state via pointer_area
pointer_area "card-hover", on_enter: true, on_exit: true, cursor: :pointer do
  container "card",
    scale: spring(to: if(model.card_hovered, do: 1.02, else: 1.0), preset: :snappy),
    shadow: ... do
    card_content(model)
  end
end

Delete with exit animation

keyed_column spacing: 4 do
  for item <- model.items do
    container item.id,
      opacity: transition(200, to: 1.0, from: 0.0, delay: 50),
      max_height: transition(200, to: 40, from: 0, delay: 50),
      exit: [
        opacity: transition(150, to: 0.0),
        max_height: transition(200, to: 0, easing: :ease_in)
      ] do
      row spacing: 8 do
        text(item.id <> "-name", item.name)
        button("delete", "x")
      end
    end
  end
end

Items fade and slide in on appear, fade and collapse on delete. The keyed_column ensures the correct item animates out (not a positional sibling).

See also