# 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
```elixir
def deps do
[
{:shift, "~> 0.1"}
]
end
```
In `assets/js/app.js`:
```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.
```heex
<.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.
```heex
<.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.
```heex
<.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.
```heex
<.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.
```heex
<.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.
```heex
<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` / `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)