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")
endThe 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 atfrom:and animates toto:. 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}
endToggle 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:
| Purpose | Example | Recommended |
|---|---|---|
| Enter | Content appears | Fade in, slide in |
| Exit | Content disappears | Fade out, slide out, collapse |
| Feedback | User action acknowledged | Scale pulse, color flash |
| Attention | Something needs action | Gentle pulse, bounce |
| State change | Value updated | Smooth transition between states |
| Progress | Work in progress | Loading shimmer, spinner |
If an animation doesn't serve one of these purposes, consider removing it. Gratuitous animation is worse than no animation.
Timing guidelines
| Context | Duration | Notes |
|---|---|---|
| Micro-interactions (hover, toggle) | 100-150ms | Fast enough to feel instant |
| Standard transitions (fade, slide) | 200-300ms | The sweet spot for most UI |
| Complex transitions (page change) | 300-500ms | Slower for large movements |
| Enter animations | 200-300ms | Users wait for content to appear |
| Exit animations | 150-200ms | Slightly faster; dismissed content should leave quickly |
| Stagger delay per item | 30-50ms | Too 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
| Prop | Widgets | Purpose |
|---|---|---|
opacity | Most widgets | Fade effects (0.0 transparent, 1.0 opaque) |
max_width | column, row, container | Expand/collapse width |
max_height | container | Expand/collapse height |
spacing | column, row | Animate gaps between children |
scale | Most widgets | Grow/shrink effect |
rotation | text, rich_text, image | Rotate in degrees |
border_radius | image, text, rich_text | Animate corner rounding |
size | text, rich_text | Animate text size |
translate_x | floating | Horizontal slide |
translate_y | floating | Vertical slide |
x | pin | Horizontal position |
y | pin | Vertical position |
width | slider | Slider track width |
height | text_editor | Editor height |
value | progress_bar | Smooth progress changes |
text_size, h1_size, h2_size, h3_size, code_size | markdown | Heading/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
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| Option | Type | Default | Description |
|---|---|---|---|
to: | number | required | Target value |
duration: | pos_integer | required | Duration in milliseconds |
easing: | easing atom | :ease_in_out | Easing curve (see catalogue) |
delay: | non_neg_integer | 0 | Delay before start |
from: | number | nil | Explicit start value (enter animations only) |
repeat: | integer / :forever | nil | Repeat count |
auto_reverse: | boolean | false | Reverse direction on each repeat |
on_complete: | atom | nil | Emit 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
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)| Option | Type | Default | Description |
|---|---|---|---|
to: | number | required | Target value |
from: | number | nil | Explicit start value |
stiffness: | number | 100 | How hard the spring pulls (higher = faster) |
damping: | number | 10 | Friction (higher = less bounce) |
mass: | number | 1.0 | Inertia (higher = slower to start/stop) |
velocity: | number | 0.0 | Initial velocity |
preset: | atom | nil | Named parameter set |
on_complete: | atom | nil | Emit completion event |
Presets
| Preset | Stiffness | Damping | Feel |
|---|---|---|---|
:gentle | 120 | 14 | Slow, smooth, no overshoot |
:snappy | 200 | 20 | Quick, minimal overshoot |
:bouncy | 300 | 10 | Quick with visible bounce |
:stiff | 400 | 30 | Very quick, crisp stop |
:molasses | 60 | 12 | Slow, 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
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 case | Choose | Why |
|---|---|---|
| Choreographed timing (page transitions, stagger) | Transition | Fixed duration, predictable coordination |
| Interactive feedback (hover, toggle, drag release) | Spring | Handles interruption gracefully, velocity carries |
| Entrance/exit | Transition | Consistent timing regardless of distance |
| Bouncy/playful | Spring with :bouncy | Natural overshoot without manual keyframes |
| Loading/progress | Transition with linear | Predictable rate |
| Subtle polish (micro-interactions) | Spring with :snappy | Fast, 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 CSScubic-bezier()function.
Easing catalogue
31 named curves plus custom cubic bezier. Pass as atoms to easing:.
Standard
| Easing | Feel |
|---|---|
:linear | Constant velocity, mechanical |
:ease_in | Gentle acceleration (sine) |
:ease_out | Gentle deceleration (sine) |
:ease_in_out | Smooth both ends (sine, default) |
Power curves
Increasing intensity from quad to quint:
| Family | In | Out | In-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
| Family | In | Out | In-out |
|---|---|---|---|
| Exponential | :ease_in_expo | :ease_out_expo | :ease_in_out_expo |
| Circular | :ease_in_circ | :ease_out_circ | :ease_in_out_circ |
Overshoot
| Family | In | Out | In-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)
endWhen 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: 200Use 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
...
endStagger
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
end30-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
endReusable 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
...
endThis 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)
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
...
endKey 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
endTesting
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
endskip_transitions/0 fast-forwards all in-flight transitions to
completion in one call:
click("#toggle")
skip_transitions()
assert find!("#panel").props[:max_width] == 200Examples
Sidebar toggle
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
endNotification 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)
endSkeleton 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
endProgress 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
endDelete 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
endItems 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
- Guide: Animation and Transitions - tutorial introduction
Plushie.Animation.Easing- easing function cataloguePlushie.Animation.Transition- transition descriptor module docsPlushie.Animation.Spring- spring descriptor module docsPlushie.Animation.Sequence- sequential chain module docsPlushie.Animation.Tween- SDK-side stateful interpolator- Accessibility reference -
prefers-reduced-motionand screen reader considerations - Testing reference -
advance_frame/1andskip_transitions/0 - cubic-bezier.com - visual curve designer