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.
41 changes: 38 additions & 3 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,7 @@ export function addAnimationWithKeyframesToScript(
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
ease?: string,
): { script: string; id: string } {
Expand All @@ -1187,6 +1188,7 @@ export function addAnimationWithKeyframesToScript(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) propEntries.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`;
});
const kfCode = `{ ${kfEntries.join(", ")} }`;
Expand Down Expand Up @@ -1354,10 +1356,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 Expand Up @@ -1387,6 +1404,24 @@ export function addKeyframeToScript(
kfNode.properties.splice(insertIdx, 0, newProp);
}

// Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
// explicitly edited by the user), update it to match the new keyframe's
// values so the element holds its final position instead of snapping back.
// Once the user drags at 100%, `_auto` is gone and we stop touching it.
if (percentage < 100 && percentage !== 0) {
const pctProps = filterPercentageProps(kfNode);
const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
if (hundredProp?.value?.type === "ObjectExpression") {
const hasAuto = hundredProp.value.properties.some(
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
);
if (hasAuto) {
const updatedProps = { ...properties, _auto: 1 as number | string };
hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
}
}

// Backfill: when the new keyframe introduces properties absent from other
// keyframes, add default values so GSAP can interpolate them.
if (backfillDefaults) {
Expand Down
60 changes: 19 additions & 41 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.totalTime(seekTime, false);
}

// Strip stale CSS offset artifacts from GSAP-targeted elements.
// These leak into the HTML when the CSS offset path fires for a
// GSAP-animated element (stale cache race). On reload, both the
// offset and GSAP transform stack, doubling the visual position.
const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]");
if (staleEls.length > 0 && state.capturedTimeline.getChildren) {
const tweenTargets = new Set<Element>();
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) tweenTargets.add(t);
}
}
} catch {
/* timeline access guard */
}
for (const el of staleEls) {
if (!tweenTargets.has(el)) continue;
const htmlEl = el as HTMLElement;
htmlEl.removeAttribute("data-hf-studio-path-offset");
htmlEl.removeAttribute("data-hf-studio-original-translate");
htmlEl.removeAttribute("data-hf-studio-original-inline-translate");
htmlEl.style.removeProperty("--hf-studio-offset-x");
htmlEl.style.removeProperty("--hf-studio-offset-y");
htmlEl.style.removeProperty("translate");
}
}
// GSAP bakes the CSS `translate` into style.transform on seek.
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
// directly here as well, since the wrapper may not be installed yet
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
}
if (resolution.diagnostics) {
postRuntimeMessage({
Expand All @@ -1002,23 +982,21 @@ 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.
{
// Only when embedded in an iframe (Studio preview) — production renders
// run as the top-level page and must not mutate element timing.
if (window.parent !== window) {
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;
// Elements inside a sub-composition host are managed by the host's
// clip window — stamping them with root-level timing makes the
// visibility system treat them as always-visible, overriding the
// parent's hidden state after the clip ends.
const isInsideSubComposition = (el: Element): boolean => {
const host = el.closest("[data-composition-src],[data-composition-file]");
return host !== null && host !== rootComp;
};

// Stamp GSAP-targeted elements
Expand All @@ -1030,8 +1008,8 @@ 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;
if (isInsideSubComposition(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
target.setAttribute("data-duration", dur);
Expand All @@ -1050,9 +1028,9 @@ 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;
if (isInsideSubComposition(el)) continue;
seen.add(el);
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", dur);
Expand Down
Loading
Loading