LiveAnimate (live_animate v0.1.0)

Copy Markdown View Source

Declarative animations for Phoenix LiveView.

LiveAnimate adds entrance/exit animations, hover/tap/drag gestures, spring physics, scroll and viewport triggers, layout (FLIP) animations, and server-driven page transitions through a single <.motion> component. Animations run on the browser's Web Animations API (WAAPI) for GPU-accelerated performance, while the Elixir side stays purely declarative.

Setup

Add the dependency, wire the JS hook into your LiveSocket, and import the CSS (the README has the step-by-step version):

# mix.exs
{:live_animate, "~> 0.1.0"}

In assets/js/app.js — spread the hooks and config into your LiveSocket, then call init(). Both are required; without the hook, <.motion> does nothing:

import LiveAnimate from "live_animate"

const liveSocket = new LiveSocket("/live", Socket, {
  ...LiveAnimate.config(),
  hooks: { ...LiveAnimate.hooks },
  params: { _csrf_token: csrfToken }
})
LiveAnimate.init()

In assets/css/app.css:

@import "../../deps/live_animate/assets/css/live_animate.css";

Import the component where you render HEEx (e.g. html_helpers in my_app_web.ex):

import LiveAnimate

Now animate anything:

<.motion id="hero" animate="fade">
  <h1>Hello, world!</h1>
</.motion>

Animation lifecycle

Each animation state is a preset name (string/atom) or a map with an :action key and optional overrides:

  • animate — entrance state played on mount (e.g., "fade" animates opacity 0 → 1)
  • exit — played before the element is removed from the DOM
  • hover — applied while the pointer is over the element
  • tap — applied while the element is pressed
  • drag — enables drag-and-release with snap-back
  • in_view — triggers when the element enters the viewport
  • scroll — triggers based on scroll position

Available presets

PresetTypeDescription
fadeentrance/exitOpacity 0 to 1
blurentrance/exitOpacity + blur
slide-upentrance/exitTranslate Y + fade
slide-downentrance/exitTranslate Y + fade
slide-leftentrance/exitTranslate X + fade
slide-rightentrance/exitTranslate X + fade
zoom-inentrance/exitScale 0.8 to 1 + fade
zoom-outentrance/exitScale 1.2 to 1 + fade
dropentrance/exitTranslate Y + scale + fade
flip-xentrance/exitRotate X 90deg + fade
flip-yentrance/exitRotate Y 90deg + fade
scale-upgestureScale to 1.05
scale-downgestureScale to 0.95
pressgestureScale to 0.92
liftgestureScale + drop shadow
tilt-leftgestureRotate -3deg
tilt-rightgestureRotate 3deg
shakekeyframeHorizontal shake
bouncekeyframeVertical bounce
pulsekeyframeScale pulse
wigglekeyframeRotational wiggle
spinkeyframeFull 360deg rotation
pingkeyframeScale up + fade out
rubber-bandkeyframeElastic stretch
floatkeyframeGentle vertical bob (pair with loop: true)
highlightkeyframeNon-displacing attention pulse (ring + tint)

Transition config

By default every animation uses a global spring easing curve. You can override the easing per-animation by adding a :transition key to any animation map:

# Spring with custom physics
<.motion id="card" animate={%{
  action: "slide-up",
  transition: %{type: "spring", stiffness: 400, damping: 10}
}} />

# Tween with CSS easing
<.motion id="card" animate={%{
  action: "fade",
  transition: %{type: "tween", ease: "ease_in_out", duration: 600}
}} />

Spring parameters

ParamDefaultDescription
stiffness100Higher = faster, snappier
damping10Higher = less overshoot
mass1Higher = more inertia

Spring transitions automatically compute a natural duration from the physics params. You can override it with transition.duration.

Tween easing values

Accepts CSS keywords ("ease_in", "ease_out", "ease_in_out", "linear"), a cubic-bezier(...) string, or a 4-element list [x1, y1, x2, y2].

Reduced motion

Every animation honors the user's prefers-reduced-motion setting by default — when "Reduce motion" is enabled at the OS level, animations collapse to instant.

Because that signal is all-or-nothing, you can override it for animations you consider decorative or essential (a spinner, a subtle confirmation pulse):

# This element keeps animating even under reduced motion
<.motion id="spinner" animate={%{action: "spin", loop: true}} respect_motion={false} />

Or flip the default globally in app.js, still honoring per-element opt-ins:

LiveAnimate.config({ respectMotion: false })  // ignore the OS setting by default

With a global respectMotion: false, an individual respect_motion={true} forces that element to honor the preference again. Prefer per-element opt-outs over the global switch — a blanket override defeats the accessibility setting.

Global defaults

Configure a global default spring and/or custom variants in your app.js:

const socketConfig = LiveAnimate.config({
  spring: { stiffness: 200, damping: 20, mass: 1 },
  variants: {
    "card-enter": { opacity: 0, transform: "scale(0.9)" },
    "card-hover": { transform: "scale(1.03)", filter: "brightness(1.1)" }
  },
  // ... other LiveSocket config
})

The global spring is used as the default easing for all animations (replacing the CSS --lm-spring variable). Per-animation :transition overrides still take priority.

Stagger

Use the stagger attribute to automatically offset each child's animation by its sibling index. The value is the delay increment in milliseconds between each child:

<div id="list" phx-update="stream">
  <.motion
    :for={{dom_id, item} <- @streams.items}
    id={dom_id}
    animate="slide-left"
    stagger={80.0}
  />
</div>

The first child plays immediately, the second after 80ms, the third after 160ms, and so on. Works with both animate and in_view.

Drag constraints

By default drag={true} allows free-form dragging with snap-back. Pass a map to configure axis locking, bounds, and elastic overscroll:

# Lock to horizontal axis only
<.motion id="slider" drag={%{axis: "x"}} />

# Constrain to a box (pixels from origin)
<.motion id="bounded" drag={%{
  constraints: %{top: -100, bottom: 100, left: -200, right: 200}
}} />

# Elastic overscroll (0 = hard clamp, 1 = no resistance, default 0.35)
<.motion id="elastic" drag={%{
  constraints: %{top: -50, bottom: 50},
  elastic: 0.5
}} />

# Stay where dropped instead of springing back to origin
<.motion id="slider" drag={%{axis: "x", snap_back: false}} />

On release the element springs back to its origin by default — a good fit for swipe/gesture interactions where the server decides the outcome (see the drag-end callback below). Pass snap_back: false to leave it where you dropped it instead, settling within the constraint bounds. The stay-put position is held client-side and re-applied after LiveView re-renders, so it survives patches. Use phx-drag-end when the server needs to know where it landed — to persist it across a full page reload, or to drive other UI.

Drag-end callback

Set phx-drag-end on the element to receive the release position server-side. On each release LiveAnimate pushes that event with the drag offset (pixels from the origin) and the element id:

<.motion id={"card-#{@card.id}"} drag={true} phx-drag-end="card_dropped">
  <p>{@card.title}</p>
</.motion>

# in your LiveView
def handle_event("card_dropped", %{"x" => x, "y" => y, "id" => id}, socket) do
  # decide what to do with the drop (e.g. dismiss past a threshold, reorder…)
  {:noreply, socket}
end

The element still snaps back to its origin visually — this is a notification of where it was dropped, not a committed position. Persist it server-side and re-render if you want the new position to stick (see the swipe-to-dismiss and drag-tracking examples in the demo).

Custom variants

Variants registered via LiveAnimate.config({ variants: { ... } }) work exactly like built-in presets — reference them by name from Elixir:

<.motion id="card" animate="card-enter" hover="card-hover" />

User variants take priority over built-in presets, so you can also override defaults (e.g. redefine "fade" with a custom opacity value).

Summary

Types

A preset name, or a map with :action and optional :duration, :delay, :transition, and extra keys.

Functions

Enables server-driven page transitions for a LiveView.

Renders an animated element.

Types

action()

@type action() :: atom() | String.t() | map()

A preset name, or a map with :action and optional :duration, :delay, :transition, and extra keys.

Functions

__using__(opts)

(macro)

Enables server-driven page transitions for a LiveView.

Add use LiveAnimate to a LiveView and declare a @transition module attribute naming a page-transition preset. When the browser navigates to this LiveView (live_navigate/live_redirect), LiveAnimate drives the View Transitions API with that preset instead of the default crossfade. By default same-LiveView live_patch updates are not transitioned (see "Applying to patches" below).

defmodule MyAppWeb.HomeLive do
  use MyAppWeb, :live_view
  use LiveAnimate
  @transition "slide-left"
end

Available presets: "fade", "blur", "slide-left", "slide-right", "slide-up", "slide-down". The transition is defined by the destination page — it controls how the previous page exits and how this page enters. (Page transitions are opacity/translate/filter only: scaling the rasterized page snapshot shimmers, so there is no "zoom" page preset — use the element-level zoom-in/zoom-out presets for scaling real elements.)

Per-transition timing

@transition accepts either a bare preset string (using the global default duration and easing) or a map with :duration (ms) and/or :easing overrides:

@transition "slide-left"

@transition %{preset: "slide-left", duration: 300, easing: "ease-out"}

:easing accepts a CSS keyword ("ease-out", "ease-in-out", "linear"), a "cubic-bezier(...)" string, or a 4-element list [x1, y1, x2, y2]. Without overrides, transitions inherit the global --lm-duration-default / --lm-spring CSS variables (shared by all View Transitions).

Applying to patches

By default a transition runs only on navigation (live_navigate / live_redirect) — the page's entrance. Same-LiveView live_patch updates (sorting a table, switching a tab, pagination) stay as plain DOM patches, so they update instantly and layout (FLIP) animations handle any repositioning. Wrapping every patch in a full-page View Transition is usually unwanted and fights FLIP, so it's opt-in via :apply_to:

# navigate only (default)
@transition "slide-left"

# transition on navigation AND live_patch within this LiveView
@transition %{preset: "slide-left", apply_to: :all}

# combine with timing overrides
@transition %{preset: "slide-left", duration: 300, apply_to: :all}

:apply_to accepts :navigate (default) or :all. The names mirror LiveView's own <.link navigate={...}> / <.link patch={...}> vocabulary.

Requires the JS setup from the README (LiveAnimate.init() wires the navigation hooks). Falls back to the default crossfade in browsers without the View Transitions API, and honors prefers-reduced-motion.

This is independent of import LiveAnimate — you still import the module to use the <.motion> component.

motion(assigns)

@spec motion(map()) :: Phoenix.LiveView.Rendered.t()

Renders an animated element.

Wraps content in a DOM element (default <div>) wired to the LiveAnimate JavaScript hook. All animation configuration is serialized as a JSON data-lm-config attribute and interpreted client-side.

Examples

Simple entrance animation:

<.motion id="card" animate="fade">
  <p>I fade in on mount.</p>
</.motion>

Hover and tap interactions:

<.motion id="btn" animate="fade" hover="scale-up" tap="press">
  <button>Click me</button>
</.motion>

Exit animation (plays before LiveView removes the element):

<.motion id="toast" animate="slide-left" exit={%{action: "fade", duration: 400}}>
  <p>Dismissible toast</p>
</.motion>

Scroll-triggered with repeat:

<.motion id="reveal" in_view={%{action: "slide-up", repeat: true}}>
  <p>I animate every time I enter the viewport.</p>
</.motion>

Layout animation (FLIP) for smooth repositioning:

<.motion id={"item-#{item.id}"} layout={true} animate="fade">
  <p>{item.text}</p>
</.motion>

Custom tag and view transition name:

<.motion id="nav-item" tag="li" name="nav-active" animate="fade">
  Active
</.motion>

Attributes

  • id (:string) (required) - DOM id, required and unique across the page (LiveView itself raises "Multiple IDs detected" on duplicates). It is not used as the element's view-transition-name — only elements given an explicit name participate as their own view-transition group; everything else animates inside the page root during navigations. In :for comprehensions derive it from the row, e.g. id={"item-#{item.id}"} or the stream dom_id.

  • tag (:string) - Defaults to "div".

  • name (:string) - Defaults to nil.

  • animate (:any) - Defaults to nil.

  • exit (:any) - Defaults to nil.

  • hover (:any) - Defaults to nil.

  • tap (:any) - Defaults to nil.

  • drag (:any) - Defaults to nil.

  • in_view (:any) - Defaults to nil.

  • scroll (:any) - Defaults to nil.

  • duration (:integer) - Defaults to 300.

  • delay (:integer) - Defaults to 0.

  • layout (:boolean) - Defaults to false.

  • stagger (:float) - Defaults to 0.0.

  • respect_motion (:boolean) - Per-element prefers-reduced-motion policy. Omit (default) to inherit the global setting; false opts this decorative animation out so it always plays; true forces it to honor the preference even when the app globally opted out via LiveAnimate.config(%{respectMotion: false}).

    Defaults to nil.

  • Global attributes are accepted.

Slots

  • inner_block (required)