Search documentation of Plushie

# Animation

Copy Markdown View Source

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:

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

`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

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

`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)

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

`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 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 CSS `cubic-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)
    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, value: %{tag: :collapsed, prop: "max_width"}}) 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()]
      else
        []
      end
    end

    # Advance on each frame:
    def update(model, %SystemEvent{type: :animation_frame, value: 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) * 180
        path([arc(60, 60, 50, 180, 180 + 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

### 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
    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

- Guide: Animation and Transitions - tutorial introduction
- `Plushie.Animation.Easing` - easing function catalogue
- `Plushie.Animation.Transition` - transition descriptor module docs
- `Plushie.Animation.Spring` - spring descriptor module docs
- `Plushie.Animation.Sequence` - sequential chain module docs
- `Plushie.Animation.Tween` - SDK-side stateful interpolator
- Accessibility reference - `prefers-reduced-motion` and screen reader considerations
- Testing reference - `advance_frame/1` and `skip_transitions/0`
- cubic-bezier.com - visual curve designer

← Previous Page Accessibility
Next Page → App Lifecycle