Animations for Phoenix LiveView, that just work.

One <.animated> component. Real spring physics. Smart defaults that read your intent and fill in the rest. Animate enter, exit, position and size straight from HEEx.

Install

def deps do
  [
    {:shift, "~> 0.1"}
  ]
end

In assets/js/app.js:

import { init as initShift } from "../../deps/shift/assets/js/shift.js"
initShift()

Then import Shift wherever you want <.animated> available — usually once in your MyAppWeb.html_helpers/0.

What you get

Everything below runs through the same <.animated> component. You declare what you want; Shift figures out the rest.

Enter & exit. initial is the state before entering, exit is the state when LiveView removes the element. Opacity fade is added for free.

<.animated :if={@open} initial={%{scale: 0.9}} exit={%{scale: 0.95}}>
  Modal contents
</.animated>

Smart-resolved targets. Mention a property in initial, leave it out of animate — Shift fills the target with its natural resting value.

<.animated initial={%{y: 16}}>
  Slides up from y=16 to y=0 (auto-resolved). Opacity fades 0 -> 1 (default).
</.animated>

Real springs, not easings. A solver runs the ODE and bakes the motion into keyframes. Interrupt, overshoot, settle — it all behaves like a physical object.

<.animated
  initial={%{scale: 0.9}}
  transition={%{type: :spring, stiffness: 260, damping: 20}}
>
  ...
</.animated>

Layout animations (FLIP), automatic. When an element's position changes between renders, Shift animates it from the old position to the new one. No opt-in needed.

<.animated :for={item <- @sorted_items} id={item.id}>
  {item.label}
</.animated>

Height & width animations. Animate from height: 0 and Shift handles the rest — padding/margin/border collapse with the box so accordions don't plateau, overflow: hidden is held throughout so content doesn't spill.

<.animated :if={@open} initial={%{height: 0}} exit={%{height: 0}}>
  Expanding panel of any height.
</.animated>

Any HTML tag. Default is <div>, but pass as= to render anything — useful for animating list items, inline text, semantic sections.

<ul>
  <.animated :for={item <- @items} as="li" id={item.id} initial={%{y: 8}}>
    {item.label}
  </.animated>
</ul>

<.animated> attributes

AttributeTypeDefaultPurpose
asstring"div"HTML tag to render — any valid element name ("li", "span", "section", ...).
initialmapnilStyle values applied before the enter animation. Drives the enter "from".
animatemapnilTarget style values for enter. Auto-resolved from initial if omitted.
exitmapnilStyle values to animate to when LiveView removes the element.
transitionmap%{}Tween: %{duration: s, delay: s, easing: "ease-in-out"}easing is any CSS easing string. Spring: %{type: :spring, stiffness: 260, damping: 20, mass: 1}.
disablelist[]Opt out of inferred behaviors: :fade, :position, :size.
classstringnilStandard HTML class attribute.

All other HTML attributes (id, data-*, aria-*, ...) pass through to the underlying <div> via :global.

Transform shorthands

Inside any of the value maps you can use shorthand names for common CSS transform functions. They compose into a single CSS transform string:

ShorthandUnitBecomes
x, y, zpxtranslateX/Y/Z(Npx)
scale, scaleX, scaleYscale(N) / scaleX(N)
rotate, rotateX, rotateYdegrotate(Ndeg) etc.
skewX, skewYdegskewX/Y(Ndeg)

CSS properties (opacity, background-color, height, ...) work as-is. Bare numbers on layout properties (height, width, padding-*, margin-*, border-*-width) get a px suffix.

How it works

A single MutationObserver watches the document. Every element with a data-shift attribute is tracked through three lifecycle phases:

  • Enter — when the element first appears (initial render or LiveView patch). Reads initial / animate from the spec and plays the transition.
  • Exit — when LiveView removes the element. Triggered by a shift:exit event dispatched from phx-remove; LiveView keeps the node alive for the exit duration before actually removing it.
  • Layout — between renders, if an element's position or size changed, a FLIP / size animation runs automatically. Opt out per-element with disable={[:position]} / disable={[:size]}.

There's some additional finesse for tricky cases — cascading exits stay in order even when morphdom shuffles kept-alive nodes, sliding-window stacks lift exits out of flow to avoid a phantom slot, and overflow: hidden is held through size animations so content doesn't spill mid-grow. None of this surfaces as API; it just works.

Requirements

  • Elixir ~> 1.15
  • Phoenix LiveView ~> 1.0
  • Modern browser with the Web Animations API (every browser since 2020)