---
name: animate-it-motion
description: Turn an Animate It motion export (JSON with "format": "animate-it/motion", copied from the Animate It Figma plugin) into working animation code — CSS, Motion for React, GSAP, Web Animations, SwiftUI, or Jetpack Compose — matching Figma's timing and easing exactly. Use when the user pastes Animate It JSON or an Animate It AI prompt, or asks to implement an animation made with Animate It.
---

# Animate It motion → code

Animate It is a Figma plugin that writes keyframe animation into Figma's Motion timeline. Its **Code** panel
exports the same numbers as JSON. Your job: rebuild that animation in the user's stack so it plays the way it
played in Figma — same times, same values, same curves.

## 1. Read the JSON

```jsonc
{
  "format": "animate-it/motion", "version": 1,
  "name": "Card Rise",
  "duration": 0.7,          // seconds ONE unit plays (loop return included)
  "totalDuration": 0.86,    // delay + latest unit delay + duration
  "delay": 0,               // seconds before anything starts
  "loop": false,            // true = repeat forever
  "target": { "kind": "box", "width": 300, "height": 200 },  // box | text | path
  "split": { "by": "layers", "stagger": 0.08, "order": "forward" },  // absent for one element
  "units": [
    { "label": "Layer 1", "delay": 0 },
    { "label": "Layer 2", "delay": 0.08 },
    { "label": "Layer 3", "delay": 0.16, "tracks": [ /* only when this unit differs */ ] }
  ],
  "tracks": [
    { "property": "translateY", "unit": "px",
      "keyframes": [ { "time": 0, "value": 40, "easing": [0, 0, 0.58, 1] },
                     { "time": 0.6, "value": 0 } ] }
  ],
  "preset": { /* the plugin's editable stack — ignore when writing code */ }
}
```

### Keyframes
- `time` is seconds from the **unit's own start** (add `delay` + the unit's `delay` for wall-clock time).
- `easing` on a keyframe is the cubic-bezier `[x1, y1, x2, y2]` of the move **from this keyframe to the next**.
  The last keyframe has none. `"hold"` means: keep this value, jump to the next at its time (CSS `step-end`).
- Before the first keyframe, hold its value. After the last, hold that value (CSS `fill-mode: both`).
- Every track has its own key times. Do **not** merge tracks into one timeline with shared keyframes unless
  their times really match — that changes the motion.
- Back easings have y outside 0..1 (e.g. `[0.45, 1.45, 0.8, 1]`): they overshoot. Keep them exact.

Named curves, if the target library wants names:

| Bezier | Name |
|---|---|
| `[0, 0, 1, 1]` | linear |
| `[0.42, 0, 1, 1]` | ease-in |
| `[0, 0, 0.58, 1]` | ease-out |
| `[0.42, 0, 0.58, 1]` | ease-in-out |
| `[0.3, -0.05, 0.7, -0.5]` | ease-in back |
| `[0.45, 1.45, 0.8, 1]` | ease-out back |
| `[0.7, -0.4, 0.4, 1.4]` | ease-in-out back |

### Properties

| property | unit | meaning |
|---|---|---|
| `opacity` | 0..1 | |
| `translateX`, `translateY` | px | offset from the element's own place; y points down |
| `scaleX`, `scaleY` | × | 1 = own size, around the center |
| `rotate` | deg | **clockwise** (already converted from Figma), around the center |
| `blur` | px | layer blur (CSS `filter: blur()`) |
| `shadowX`, `shadowY`, `shadowBlur`, `shadowSpread` | px | a black drop shadow |
| `shadowAlpha` | 0..1 | that shadow's opacity |
| `strokeWidth` | px | inside stroke (border drawn inward) |
| `cornerRadius` | px | corner radius |
| `trimStart`, `trimEnd` | 0..1 | drawn part of a path (Draw): 0..1 along its length |
| `fillColor`, `strokeColor` | `#rrggbb` | fill (text color for text) and stroke color |

`"additive": true` (shadow, stroke, radius): add the value to what the element already has. If the element has
no shadow/stroke/radius of its own, use the value as is.

### Units, split, stagger
- One element: a single unit, no `split`.
- `split.by: "layers"`: several sibling elements, one per unit, in `units` order (the plugin already applied its
  line-up and `order`; just use each unit's `delay`).
- `split.by: "letters" | "words" | "lines"`: cut the text into one element per unit (`label` is its text).
  Make each piece `display: inline-block` so transforms work; keep spaces as plain text between pieces (letters
  and words skip spaces); for lines put a line break between. Put the full text in an `aria-label` on the parent
  and `aria-hidden` on the pieces.
- A unit with its own `tracks` (random Vary or Random colors) plays those instead of the shared `tracks`.

### Size
Distances are pixels **for `target.width × target.height`** (percent settings were resolved on that layer).
If the real element has a different size, scale x distances by `realWidth / width` and y by `realHeight / height`
— or keep pixels if the design is fixed size. Say which you chose.
For split text, percent settings were resolved on **one line** (`split.lineHeight` × `split.lineHeight`), so every
piece moves the same distance; scale those with the real text's line height, not the paragraph's size.

### Loop
`loop: true`: repeat forever with period `duration`; the tracks already return to the first pose. Keep each
unit's delay only on the first cycle unless the library offsets repeats the same way (CSS does: the delay shifts
every cycle's phase, which matches Figma).

## 2. Write the code

Always: respect `prefers-reduced-motion` (skip or jump to the end pose), keep numbers exact, and don't add
motion that isn't in the JSON.

### CSS (the plugin's own CSS tab does this)
One `@keyframes` per track over the **whole** `duration` (percent = `time / duration`), with
`animation-timing-function` inside each keyframe (it applies to the move leaving it), `linear` on the
`animation` shorthand, `fill-mode: both`. Parts that share a CSS property (translate x/y, shadow parts, scale
x/y) animate registered custom properties so each keeps its own keys:

```css
@property --ai-y { syntax: "<length>"; inherits: false; initial-value: 0px; }
@keyframes card-rise-translate-y {
  0%      { --ai-y: 40px; animation-timing-function: cubic-bezier(0, 0, 0.58, 1); }
  85.714% { --ai-y: 0px; }
  100%    { --ai-y: 0px; }
}
.card-rise {
  translate: 0px var(--ai-y);
  animation: card-rise-translate-y 0.7s linear both;
  animation-delay: var(--delay, 0s);   /* per unit: style="--delay: 0.08s" */
}
```
Mapping: shadow → `box-shadow` (box), `text-shadow` (text, no spread), `filter: drop-shadow()` (path);
stroke → `outline` with negative `outline-offset` (box), `-webkit-text-stroke` (text), `stroke-width` (path);
trim → `pathLength="1"`, `stroke-dasharray: calc(end - start) 2; stroke-dashoffset: calc(-1 * start)`.

### Motion for React (`motion/react`)
Use keyframe arrays with `times` (0..1 of the track's span) and per-segment `ease`:

```tsx
const t = track.keyframes
const span = t[t.length - 1].time - t[0].time
<motion.div
  initial={{ y: t[0].value }}
  animate={{ y: t.map(k => k.value) }}
  transition={{ y: {
    duration: span,
    delay: doc.delay + unit.delay + t[0].time,
    times: t.map(k => (k.time - t[0].time) / span),
    ease: t.slice(0, -1).map(k => k.easing === "hold" ? () => 0 : k.easing),
    repeat: doc.loop ? Infinity : 0,
  } }}
/>
```
Property keys: translateX→`x`, translateY→`y`, rotate→`rotate`, scaleX/Y→`scaleX`/`scaleY`, opacity, blur→
`filter: "blur(Npx)"`, shadow parts → build a `boxShadow` string per keyframe when their times match, else a
`useTime`/`useTransform` per part. With a loop, pad each track to the full `duration` so tracks stay in phase.

### GSAP
One timeline per unit at `doc.delay + unit.delay`; per track, chain `.to()` tweens between consecutive keyframes
at position `k.time`, `duration: next.time - k.time`, `ease: CustomEase.create("", "M0,0 C x1,y1 x2,y2 1,1")`
(or `"none"` for linear, `"steps(1)"` for hold). `gsap.set()` the first values first. Loop: `repeat: -1` on
the timeline with its length padded to `duration`.

### Web Animations API
Per track: `el.animate(frames, { duration: duration * 1000, delay, fill: "both", iterations })` with frames at
`offset = time / duration`, `easing: "cubic-bezier(...)"` on each frame, plus 0 and 1 hold frames. Use
`composite: "add"` or registered custom properties so tracks on the same CSS property don't override each
other.

### SwiftUI (iOS 17+)
`keyframeAnimator` with one `KeyframeTrack` per property. A SwiftUI keyframe's curve shapes the move **into**
it, so the easing of JSON keyframe *k* goes on the keyframe for *k + 1*:

```swift
KeyframeTrack(\.y) {
  MoveKeyframe(40)                                   // first value (after a LinearKeyframe hold if time > 0)
  LinearKeyframe(0, duration: 0.6,                   // next.time - k.time
    timingCurve: .bezier(startControlPoint: UnitPoint(x: 0, y: 0),
                         endControlPoint: UnitPoint(x: 0.58, y: 1)))
}
```
"hold" → `MoveKeyframe` at the next time after a `LinearKeyframe` to the same value. Rotation is clockwise-
positive on screen and y points down, same as the JSON. Pad every track to `duration` when it loops
(`repeating: true`).

### Jetpack Compose
`Animatable` per property or `updateTransition` with `keyframes { durationMillis = …; value at ms using
CubicBezierEasing(x1, y1, x2, y2) }` — `keyframes` applies an easing to the segment **starting** at that
keyframe, which matches this format. `graphicsLayer { translationX = …; rotationZ = … }` (px → use
`with(density)` if the design is in dp).

## 3. Check your work
- Total runtime equals `totalDuration`.
- At `time` of each keyframe the value is exact; between, it follows that keyframe's curve.
- Split text reads correctly to screen readers and wraps like the original.
- Reduced motion shows the final pose.
