LiveAnimate
Declarative UI animations for Phoenix LiveView — entrance/exit, gestures, springs, and page transitions, straight from HEEx with (almost) no JavaScript.
▶ Live demo · Documentation · Hex
Add one <.motion> component to your HEEx and get entrance/exit animations, hover/tap/drag gestures, spring physics, scroll triggers, layout (FLIP) animations, and server-driven page transitions. Animations run on the browser's Web Animations API for GPU-accelerated performance; the Elixir side stays purely declarative.
<.motion id="hero" animate="slide-up" hover="lift">
<h1>Hello, world!</h1>
</.motion>
Why LiveAnimate
- Declarative from HEEx. Describe the animation on the element — no hand-written JavaScript, no imperative transition code.
- Built for the LiveView lifecycle. Exit animations that play before the server removes an element, FLIP layout animations that survive stream reflows and reorders, and whole-page transitions you declare on a LiveView with
@transition. - Rich motion out of the box. Spring physics, velocity-aware drag, staggered lists, scroll/viewport triggers, and more than two dozen presets — plus your own inline keyframes and named variants.
- No runtime npm dependency. The animation engine is hand-rolled and ships with the package; setup is one hook and one CSS import.
- Accessible by default. Honors
prefers-reduced-motion, with a per-element opt-out for motion you consider decorative.
Compared to JS.transition
Phoenix ships Phoenix.LiveView.JS.transition, which toggles CSS classes — you hand-write the keyframes and CSS yourself, and there's no spring physics, gestures, drag, FLIP layout, or page transitions. LiveAnimate gives you all of that declaratively, so most UI motion becomes a preset name instead of a stylesheet plus a JS command.
Installation
1. Add the dependency
# mix.exs
def deps do
[
{:live_animate, "~> 0.1.0"}
]
end
2. Set up JavaScript
// assets/js/app.js
import LiveAnimate from "live_animate"
let liveSocket = new LiveSocket("/live", Socket, {
...LiveAnimate.config(),
hooks: { ...LiveAnimate.hooks },
params: { _csrf_token: csrfToken }
})
LiveAnimate.init()
3. Import CSS
/* assets/css/app.css */
@import "../../deps/live_animate/assets/css/live_animate.css";
4. Import the component
# lib/my_app_web.ex
defp html_helpers do
quote do
import LiveAnimate
# ...
end
end
Quick start
<%# Fade in on mount %>
<.motion id="hero" animate="fade">
<h1>Hello, world!</h1>
</.motion>
<%# Hover and tap interactions %>
<.motion id="btn" hover="scale-up" tap="press">
<button>Click me</button>
</.motion>
<%# Exit animation %>
<.motion id="toast" animate="slide-left" exit="fade">
<p>Dismissible toast</p>
</.motion>
<%# Drag — axis-locked, snap-back, release position pushed to the server %>
<.motion id="card" drag={%{axis: "x"}} phx-drag-end="card_released">
<div>Drag me</div>
</.motion>
Page transitions
Animate the whole page across LiveView navigation. Unlike the examples above, this isn't a <.motion> attribute — add use LiveAnimate to a LiveView (after use ..., :live_view) and declare a @transition:
defmodule MyAppWeb.PageLive do
use MyAppWeb, :live_view
use LiveAnimate
@transition "slide-left"
# ...
end
@transition also accepts a map for per-page timing — @transition %{preset: "slide-left", duration: 300, easing: "ease-out"}. By default it runs only on navigation (live_navigate/live_redirect); add apply_to: :all to also transition same-LiveView live_patch updates. See the documentation for apply_to and shared-element morphing. Falls back to an instant swap where the View Transitions API isn't available.
Persistent elements & navigation (once)
Entrance animations (animate) run when the element mounts. That's right for content
that mounts intentionally — but persistent chrome (sidebars, top bars, footers) can remount
far more often than it first appears:
- Navigating to a different LiveView re-renders the live layout; depending on how the DOM
diffs, a
phx-hookelement can be torn down and re-added, re-firing its entrance. - If your routes aren't wrapped in a shared
live_session, a<.link navigate>across LiveViews is a full page reload — every hook re-initializes, so every mount entrance replays on every navigation.
The result: a sidebar with a staggered slide-right entrance re-runs its cascade on every
click. Add once to play the entrance at most once per scope, then snap straight to the
rest state on later mounts.
Without once — the cascade replays on every navigation:
<nav>
<.motion :for={item <- @nav} id={"nav-#{item.id}"} animate="slide-right" stagger={60}>
<.link navigate={item.path}>{item.label}</.link>
</.motion>
</nav>
With once — plays once when you first land, then stays put as you navigate:
<nav>
<.motion :for={item <- @nav} id={"nav-#{item.id}"} animate="slide-right" stagger={60} once>
<.link navigate={item.path}>{item.label}</.link>
</.motion>
</nav>
once (or once={true}) defaults to the :session scope. Pick a scope explicitly when you
need different persistence:
| Scope | Stored in | Survives full reload? | Survives new tab/session? |
|---|---|---|---|
once={:page} |
in-memory | ❌ | ❌ |
once / once={:session} |
sessionStorage |
✅ | ❌ |
once={:forever} |
localStorage |
✅ | ✅ (plays once, ever) |
The "already played" flag defaults to the element id. When the id is derived from volatile
data — or you want several elements to share one flag — pin it with remember_as:
<.motion id={"sidebar-#{@menu.name}"} animate="slide-right" once remember_as="sidebar-nav" />
Notes: once governs the animate mount entrance only. It's a no-op where there's nothing
to gate — gesture animations (hover/tap/drag) are event-driven and never replayed, and a
scroll/viewport trigger (in_view/scroll) drives its own animation instead of the mount. It's
orthogonal to prefers-reduced-motion, and degrades gracefully where storage is unavailable
(private mode) by simply animating every time.
Available presets
| Preset | Type | Description |
|---|---|---|
fade |
entrance/exit | Opacity 0 to 1 |
blur |
entrance/exit | Opacity + blur |
slide-up |
entrance/exit | Translate Y + fade |
slide-down |
entrance/exit | Translate Y + fade |
slide-left |
entrance/exit | Translate X + fade |
slide-right |
entrance/exit | Translate X + fade |
zoom-in |
entrance/exit | Scale 0.8 to 1 + fade |
zoom-out |
entrance/exit | Scale 1.2 to 1 + fade |
drop |
entrance/exit | Translate Y + scale + fade |
flip-x |
entrance/exit | Rotate X 90deg + fade |
flip-y |
entrance/exit | Rotate Y 90deg + fade |
scale-up |
gesture | Scale to 1.05 |
scale-down |
gesture | Scale to 0.95 |
press |
gesture | Scale to 0.92 |
lift |
gesture | Scale + drop shadow |
tilt-left |
gesture | Rotate -3deg |
tilt-right |
gesture | Rotate 3deg |
shake |
keyframe | Horizontal shake |
bounce |
keyframe | Vertical bounce |
pulse |
keyframe | Scale pulse |
wiggle |
keyframe | Rotational wiggle |
spin |
keyframe | Full 360deg rotation |
ping |
keyframe | Scale up + fade out |
rubber-band |
keyframe | Elastic stretch |
float |
keyframe | Gentle vertical bob (pair with loop: true) |
highlight |
keyframe | Non-displacing attention pulse (ring + tint) |
Browser support
- Core animations (entrance/exit, gestures, springs, keyframes) use the Web Animations API and individual transform properties — supported across current Chromium, Firefox, and Safari.
- Page transitions use the View Transitions API (Chromium-based browsers and Safari 18+). Where it isn't available, navigation falls back to an instant swap with no error.
- All animations respect the user's
prefers-reduced-motionsetting and collapse to instant when it's enabled — override per element withrespect_motion={false}.
Documentation
Full documentation is available on HexDocs.
License
MIT - see LICENSE.