Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"guides/prompting",
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
"guides/keyframes",
"guides/rendering",
"guides/remove-background",
"guides/hdr",
Expand Down
141 changes: 141 additions & 0 deletions docs/guides/keyframes.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
title: Keyframes & Arc Motion
description: "Edit GSAP keyframes visually in Studio — timeline diamonds, arc motion paths, and gesture recording."
---

Studio gives you visual tools to create and edit GSAP keyframes without writing code. You can adjust animation properties in the Design Panel, convert straight-line motion into curved arcs, and record gesture-based motion by dragging elements in the preview.

## Timeline Keyframe Diamonds

When you open a composition in Studio, the timeline shows **diamond markers** on clips that have GSAP animations. Each diamond represents a keyframe — a point in time where a property value is set.

- **Start diamond** — where the tween begins (e.g., `x: 0`)
- **End diamond** — where the tween ends (e.g., `x: 1000`)
- Elements with multiple tweens show multiple diamond pairs

<Note>
Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline.
</Note>

## Editing Animation Properties

Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows:

- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`)
- **Timing** — Length (duration) and Starts at (position on timeline)
- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`)
- **Speed curve** — Visual preview of the easing function
- **Properties** — Each animated property (Move X, Move Y, Scale, Opacity, etc.) with its target value

<Steps>
<Step title="Select an element">
Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right.
</Step>
<Step title="Edit property values">
Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload.
</Step>
<Step title="Change the ease">
Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live.
</Step>
<Step title="Verify in Code tab">
Switch to the Code tab to see the generated GSAP code. Every Design Panel edit writes valid GSAP that renders identically in preview and headless export.
</Step>
</Steps>

## Arc Motion

Arc Motion converts a straight-line x/y animation into a curved path using GSAP's MotionPathPlugin. Instead of moving in a straight diagonal, the element follows a smooth arc — like tossing an object into a basket.

### When to Use It

Use Arc Motion when an element has both `x` and `y` properties in a single tween. Common examples:
- Add-to-cart animations (item arcs from product to cart icon)
- Throw/toss effects
- Any motion that should feel physical rather than robotic

### Step-by-Step

<Steps>
<Step title="Select an element with x/y motion">
The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline.
</Step>
<Step title="Toggle Arc Motion ON">
In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON.
</Step>
<Step title="Adjust Curviness">
The **Curviness** slider controls how exaggerated the arc is:
- `0` — straight line (no curve)
- `1` — gentle natural arc
- `1.5–2.0` — smooth throw feel (recommended)
- `3.0` — extreme loop

Scrub the timeline to preview the arc in real time.
</Step>
<Step title="Toggle Auto-Rotate (optional)">
Enable **Auto-Rotate** to make the element rotate to face the direction of travel along the arc. This adds a "thrown" feel vs. a "floating" feel.
</Step>
<Step title="Verify the generated code">
Switch to the Code tab. You'll see:

```javascript
tl.to("#element", {
scale: 0.4,
opacity: 0,
duration: 1.0,
ease: "power2.inOut",
motionPath: {
path: [{x: 0, y: 0}, {x: 1400, y: -280}],
curviness: 1.5,
autoRotate: true
}
}, 1.0);
```

The MotionPathPlugin CDN script is added automatically.
</Step>
<Step title="Disable to restore straight motion">
Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values.
</Step>
</Steps>

<Note>
Arc Motion works for flat `.to()` tweens with x/y properties. It synthesizes waypoints from `{x: 0, y: 0}` (start) to `{x: targetX, y: targetY}` (end). For more complex paths with intermediate waypoints, edit the `motionPath.path` array directly in the Code tab.
</Note>

## Gesture Recording

Record motion by physically dragging an element in the preview while the timeline plays. The pointer path is simplified and converted into GSAP keyframes automatically.

<Steps>
<Step title="Select an element">
Click the element you want to animate in the preview.
</Step>
<Step title="Click Record or press R">
In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing.
</Step>
<Step title="Drag the element">
Move the element in the preview by dragging it. Your pointer motion is sampled at ~60fps. A trail overlay shows the path you're drawing.
</Step>
<Step title="Stop recording">
Press R again or wait for the timeline to reach the end. Recording stops, the motion is simplified (reducing ~180 raw samples to 5–15 clean keyframes), and the keyframes are written to the GSAP script immediately.
</Step>
<Step title="Review or undo">
The timeline seeks back to the recording start so you can scrub through the result. If you don't like it, press **Cmd+Z** to undo and try again.
</Step>
</Steps>

## Clipboard Context

The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard:

```
Element: Title (#title)
File: index.html:15
Position: x=100, y=40
Size: 264×43
Tag: <div>
Animation: from() 0.5s at 0s, ease: power2.out
Properties: x: -40, opacity: 0
```

Paste this into any AI agent prompt to give it spatial context about the element — its position, size, animation, and source location.
21 changes: 18 additions & 3 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1354,10 +1354,25 @@ export function addKeyframeToScript(
ease?: string,
backfillDefaults?: Record<string, number | string>,
): string {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);

if (!kfNode) {
script = convertToKeyframesInScript(script, animationId);
loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
}

const pctKey = `${percentage}%`;
const newValueNode = buildKeyframeValueNode(properties, ease);
Expand Down
15 changes: 0 additions & 15 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,25 +1002,12 @@ export function initSandboxRuntimeModular(): void {
});
// Stamp data-start / data-duration on GSAP-targeted elements that lack
// them so the Studio timeline can discover individual animated elements.
// Skip elements whose ancestor already carries timing — stamping them
// would override the parent's clip visibility and cause preview/render
// parity drift.
{
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set<Element>();

const hasTimedAncestor = (el: HTMLElement): boolean => {
let cursor = el.parentElement;
while (cursor) {
if (cursor.hasAttribute("data-start")) return true;
if (cursor === rootComp) return false;
cursor = cursor.parentElement;
}
return false;
};

// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
Expand All @@ -1030,7 +1017,6 @@ export function initSandboxRuntimeModular(): void {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
if (hasTimedAncestor(target)) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
Expand All @@ -1050,7 +1036,6 @@ export function initSandboxRuntimeModular(): void {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
if (hasTimedAncestor(el)) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
Expand Down
45 changes: 39 additions & 6 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ export function patchElementInHtml(
}
break;
case "text-content":
if (op.value != null) htmlEl.textContent = op.value;
if (op.value != null) {
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
textTarget.textContent = op.value;
}
break;
}
}
Expand All @@ -219,6 +223,35 @@ export interface SplitElementResult {
newId: string | null;
}

function resolveElementTiming(el: Element): {
start: number;
duration: number;
usesDataEnd: boolean;
} {
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const usesDataEnd = el.hasAttribute("data-end");
const duration = usesDataEnd
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
return { start, duration, usesDataEnd };
}

function setElementDuration(
el: Element,
start: number,
duration: number,
usesDataEnd: boolean,
): void {
if (usesDataEnd) {
const endTime = String(Math.round((start + duration) * 1000) / 1000);
el.setAttribute("data-end", endTime);
el.removeAttribute("data-duration");
} else {
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
el.removeAttribute("data-end");
}
}

export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
Expand All @@ -229,8 +262,7 @@ export function splitElementInHtml(
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };

const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
const { start, duration, usesDataEnd } = resolveElementTiming(el);
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
Expand All @@ -241,7 +273,7 @@ export function splitElementInHtml(
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);

// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
Expand All @@ -251,15 +283,16 @@ export function splitElementInHtml(
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
);
}

// Trim the original element's duration
el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
setElementDuration(el, start, firstDuration, usesDataEnd);

// Insert clone after original
if (el.nextSibling) {
Expand Down
Loading
Loading