Shift
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
| Attribute | Type | Default | Purpose |
|---|---|---|---|
as | string | "div" |
HTML tag to render — any valid element name ("li", "span", "section", ...). |
initial | map | nil | Style values applied before the enter animation. Drives the enter "from". |
animate | map | nil |
Target style values for enter. Auto-resolved from initial if omitted. |
exit | map | nil | Style values to animate to when LiveView removes the element. |
transition | map | %{} |
Tween: %{duration: s, delay: s, easing: "ease-in-out"} — easing is any CSS easing string. Spring: %{type: :spring, stiffness: 260, damping: 20, mass: 1}. |
disable | list | [] |
Opt out of inferred behaviors: :fade, :position, :size. |
class | string | nil | Standard 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:
| Shorthand | Unit | Becomes |
|---|---|---|
x, y, z | px | translateX/Y/Z(Npx) |
scale, scaleX, scaleY | — | scale(N) / scaleX(N) |
rotate, rotateX, rotateY | deg | rotate(Ndeg) etc. |
skewX, skewY | deg | skewX/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/animatefrom the spec and plays the transition. - Exit — when LiveView removes the element. Triggered by a
shift:exitevent dispatched fromphx-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)