Build a Prompt-to-Lottie Studio
Build a React studio that turns prompts into portable Lottie animations.
Introduction
30 Second Summary
Motion ideas can be easy to describe but slow to build by hand. A small timing change can mean another round of manual edits.
In this project, you will build a local motion studio with React plus TypeScript that turns plain-language prompts into Lottie animation recipes. The studio accepts SVG or Lottie JSON assets before using the Claude API to refine movement into a self-contained export.
What You'll Build
MotionPrompt Studio turns a visual plus a plain-language direction into a live animation ready to download.
By the end of this project, you'll have:
- A live asset stage that displays an imported SVG or Lottie JSON file with looping movement.
- A prompt composer that turns a direction such as Fade in, overshoot slightly, then drift upward into visible motion. Follow-up directions refine the current result.
- A portable Lottie export you can download as a self-contained JSON file. Re-importing the file plays the same motion.
- Secret Mission: Stress-test the exporter with three motion styles. Document the compatibility boundary for SVG and Lottie inputs.
Are there any prerequisites?
You should be comfortable with React and TypeScript. A Claude Console account is required for the guided API key setup.
Before We Start
A real asset and a clear workflow goal give your motion studio a purpose before any setup begins. This checkpoint connects prompt-driven animation to something you genuinely want to create.
Set Up the Studio Workspace
Your chosen asset now has a purpose. The studio needs a browser interface for the preview.
A separate local Express server keeps the Claude API credential away from the browser. Exact package versions keep the React and TypeScript workspace reproducible.
In this step, get ready to:
- Prepare a Cursor workspace with a compatible Node.js runtime.
- Install the pinned Vite dependencies with npm.
- Protect your Claude API credential behind a healthy local Express server that uses the Anthropic TypeScript SDK.
Verify Node.js and create the workspace
Vite requires a compatible Node.js runtime before it can install or run the studio. This project uses Node.js 20.19.0 or newer.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check your installed Node.js version by running this command:
node --version
What does this command do?
The command asks Node.js to print its installed version. The result determines whether your runtime can support this project.
✔️ I see version 20.19.0 or higher
Your Node.js runtime meets the project requirement. That removes the first compatibility risk from the workspace.
ⓧ I see an older version
Your installed runtime is below the project requirement. Upgrade Node.js before creating the workspace.
- Open the official Node.js download page.
- Download the macOS Installer (.pkg) for Node.js 20.19.0 or newer.
- Complete the Node.js installer.
- Return to Terminal after the installation finishes.
- Check the upgraded version by running this command:
node --version
What should I see?
Terminal should now print Node.js 20.19.0 or a newer version. The upgraded runtime is ready for Vite.
ⓧ Command not found
Terminal cannot find a Node.js installation. Install the runtime before continuing.
- Open the official Node.js download page.
- Download the macOS Installer (.pkg) for Node.js 20.19.0 or newer.
- Complete the Node.js installer.
- Return to Terminal after the installation finishes.
- Confirm the installation by running this command:
node --version
What should I see?
Terminal should print a Node.js version number. A result of 20.19.0 or higher means you can continue.
The runtime is ready. Next, give the project a predictable home on your Desktop.
- Press Cmd+Space to open Spotlight.
- Type Cursor into Spotlight.
- Press Enter to open Cursor.
- Click File in the macOS menu bar.
- Select Open Folder.
- Select Desktop in the folder picker.
- Click the new-folder control in the folder picker.
- Enter motion-prompt-studio as the folder name.
The folder picker now shows a dedicated home for the studio. Opening this folder keeps every project file in one place.
- Create the motion-prompt-studio folder.
- Select the new motion-prompt-studio folder.
- Open the selected folder in Cursor.
Cursor should show motion-prompt-studio in the Explorer sidebar. Your empty workspace is ready for its pinned configuration.
Add the pinned workspace configuration
The manifest locks every dependency to an exact version. This keeps your installation aligned with the code used throughout the project.
- Create package.json inside the motion-prompt-studio folder using the Explorer sidebar.
- Define the project scripts and pinned dependencies by pasting this code:
{
"name": "motion-prompt-studio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "tsx watch server/index.ts",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@anthropic-ai/sdk": "0.135.0",
"dotenv": "18.0.8",
"express": "5.3.0",
"lottie-web": "5.13.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"zod": "4.6.7"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4"
}
}
What does this manifest control?
- The scripts provide one command for the browser app, one command for the local server, and one command for the production build.
- The dependencies provide the application libraries used at runtime.
- The development dependencies provide the compiler, type definitions, server watcher, and Vite integration.
- Save package.json.
- Confirm that package.json appears in Cursor's Explorer sidebar.
- Click Terminal in Cursor's menu bar.
- Select New Terminal.
- Install the pinned project dependencies by running:
npm install
What does this command do?
The command reads package.json and downloads each pinned package. The local installation gives the studio its browser, server, validation, and animation libraries.
- Wait for the installation command to finish.
- Confirm that Terminal reports the installation without a dependency error.
- Confirm that node_modules appears in the Explorer sidebar.
Dependency installation failed?
Check that Terminal is inside the motion-prompt-studio folder. The terminal prompt should include that folder name.
Confirm that package.json contains valid JSON with matching braces and commas.
Need help with the installation error?
The packages are installed. The remaining configuration tells TypeScript how to check the project and tells Vite how to connect the browser to the local server.
- Create tsconfig.json inside the motion-prompt-studio folder using the Explorer sidebar.
- Configure TypeScript by pasting this code:
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["node"]
},
"include": ["src", "server", "vite.config.ts"]
}
What does this configuration do?
This configuration enables strict TypeScript checks for the browser code and server code. It checks the project without emitting duplicate JavaScript files.
- Save tsconfig.json.
- Confirm that tsconfig.json appears beside package.json in the Explorer sidebar.
- Create vite.config.ts inside the motion-prompt-studio folder using the Explorer sidebar.
- Configure Vite by pasting this code:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8787"
}
}
});
What does this configuration do?
- The React plugin lets Vite process the studio's React components.
- The proxy forwards browser requests beginning with /api to the local server on port 8787.
- Save vite.config.ts.
- Confirm that vite.config.ts appears in the Explorer sidebar.
The browser entry document provides the page shell that Vite uses later. Its root element becomes the mounting point for the React studio.
- Create index.html inside the motion-prompt-studio folder using the Explorer sidebar.
- Add the browser entry document by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<title>MotionPrompt Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
What does this document provide?
The root element gives React a place to render the interface. The module script points Vite to the application entry file used in the next step.
- Save index.html.
- Confirm that index.html appears in the Explorer sidebar.
The final two shared files define the secret boundary. One file excludes local values from Git while the other documents the required environment variable without containing a real credential.
- Create .gitignore inside the motion-prompt-studio folder using the Explorer sidebar.
- Define the ignored files by pasting this code:
node_modules
dist
.env
.DS_Store
Why ignore these files?
Dependencies and build output can be recreated from the project configuration. The .env entry prevents the local credential file from entering Git.
- Save .gitignore.
- Confirm that .gitignore appears in the Explorer sidebar.
- Create .env.example inside the motion-prompt-studio folder using the Explorer sidebar.
- Document the required environment variable by pasting this template:
ANTHROPIC_API_KEY=your-api-key-here
Why keep an example file?
The example records the environment variable name that the server expects. Its placeholder contains no live credential, so the file is safe to keep with the project.
- Save .env.example.
- Confirm that .env.example appears beside .gitignore in the Explorer sidebar.
Protect the API key and start the server
The next value is a live credential. Keeping it in an ignored local file lets the server use it without exposing it to the browser or Git.
Creating a key does not call the Claude API. API requests are metered when the motion generation feature uses the key later.
- Open the Claude Console API keys page.
- Sign in to your Claude Console account.
You should now see the API keys page for your Console organization. This is where personal credentials can be created and managed.
- Click Create key.
- Enter motion-prompt-studio as the key name.
The form now identifies which local project uses this credential. A specific name makes the key easier to disable or delete later.
- Submit the key creation form.
- Copy the personal API key when Claude Console displays it.
Treat the copied value like a password. Keep the reveal page out of screenshots and shared recordings.
- Return to the motion-prompt-studio workspace in Cursor.
- Create .env inside the motion-prompt-studio folder using the Explorer sidebar.
- Add the environment variable template by pasting this line:
ANTHROPIC_API_KEY=your-api-key-here
How does the secret boundary work?
The server reads ANTHROPIC_API_KEY from .env when it starts. The browser receives no copy of this file.
- Replace your-api-key-here with the personal API key you copied.
- Save .env.
- Close the .env editor tab to keep the credential out of view.
The health-only server checks whether the credential exists without sending a Claude request. This gives you a safe way to test the server-side setup.
- Create a server folder inside motion-prompt-studio using the Explorer sidebar.
- Create index.ts inside the server folder using the Explorer sidebar.
- Add the health-only Express server by pasting this code:
import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const port = 8787;
const anthropic = process.env.ANTHROPIC_API_KEY ? new Anthropic() : null;
app.get("/api/health", (_request, response) => {
response.json({
ok: true,
claudeConfigured: Boolean(anthropic)
});
});
app.listen(port, () => {
console.log(`MotionPrompt server listening on http://localhost:${port}`);
});
What does this server do?
- The environment import loads values from the local .env file.
- The Anthropic client exists only when ANTHROPIC_API_KEY has a value.
- The health route reports whether Express is running and whether the client was configured.
- The listener keeps the server available on port 8787.
- Save server/index.ts.
- Confirm that the Explorer sidebar shows index.ts inside the server folder.
Use the tabs below to compare the complete workspace configuration before starting the server.
✔️ Awesome, I've got everything
Save every open file in Cursor. Keep .env closed so the personal API key stays hidden.
ⓧ I'd like to double check the full code
Your .env file should contain ANTHROPIC_API_KEY= followed by your personal key. Keep that secret hidden while you compare the remaining files.
{
"name": "motion-prompt-studio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "tsx watch server/index.ts",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@anthropic-ai/sdk": "0.135.0",
"dotenv": "18.0.8",
"express": "5.3.0",
"lottie-web": "5.13.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"zod": "4.6.7"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["node"]
},
"include": ["src", "server", "vite.config.ts"]
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8787"
}
}
});
node_modules
dist
.env
.DS_Store
ANTHROPIC_API_KEY=your-api-key-here
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<title>MotionPrompt Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const port = 8787;
const anthropic = process.env.ANTHROPIC_API_KEY ? new Anthropic() : null;
app.get("/api/health", (_request, response) => {
response.json({
ok: true,
claudeConfigured: Boolean(anthropic)
});
});
app.listen(port, () => {
console.log(`MotionPrompt server listening on http://localhost:${port}`);
});
The server command runs in watch mode and keeps control of its terminal. Leave that terminal running while you perform the health check.
- Return to the Cursor terminal you used for the dependency installation.
- Start the local server by running:
npm run server
What does this command do?
The command starts server/index.ts through the watch script in package.json. Saving the server file later triggers an automatic restart.
- Wait for the server startup message in Terminal.
- Confirm that Terminal shows MotionPrompt server listening on http://localhost:8787.
The server is live and reading its local environment. That is the credential boundary the browser app relies on.
Server did not start?
Check that server/index.ts is saved inside the server folder. Confirm that the terminal prompt points to motion-prompt-studio.
If the message mentions port 8787 already being used, stop the older project server with Control+C before trying again.
Help me diagnose the local server startup problem.
- Create a second terminal using Cursor's Terminal menu.
Before you run the health check, do you expect both response fields to be true?
- Check the running server by running this command in the second terminal:
curl http://localhost:8787/api/health
What does this health check prove?
The request reaches the local Express route without sending a motion prompt. Its response checks the server process and the presence of the Claude credential.
You should see a JSON response containing "ok":true and "claudeConfigured":true. Both values confirm that the backend is reachable and the credential was loaded.
Health check not returning two true values?
If the request cannot connect, confirm that the first terminal still shows the running server process. Restart it if that process was stopped.
If claudeConfigured is false, confirm that .env uses the exact name ANTHROPIC_API_KEY. Restart the server after saving the file.
Help me debug the health response.
Your pinned workspace is installed and your credential stays behind a working server endpoint. Next, you will turn the empty browser shell into a looping Lottie motion preview.
Render the First Motion Preview
Your Express health server confirms that the credential boundary works. The browser still needs something visual to render.
You will turn an embedded SVG into a looping Lottie composition. A React component will pass that composition to lottie-web for playback.
In this step, get ready to:
- Define the motion recipe and Lottie document types.
- Convert the sample SVG into an animated Lottie composition.
- Render the studio interface and test the hard-coded prompt experience.
Define the motion data and composition
A motion recipe needs a predictable shape before it can become keyframes. TypeScript interfaces give every frame the same position, scale, rotation, and opacity fields.
- Create src/types.ts inside the motion-prompt-studio folder using Cursor's file explorer.
- Add the frame and recipe interfaces by pasting this code:
export interface MotionFrame {
at: number;
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
}
export interface AnimationRecipe {
name: string;
duration: number;
loop: boolean;
frames: MotionFrame[];
}
What do these types describe?
- MotionFrame stores one normalized point in the animation timeline.
- AnimationRecipe groups the frames with a name, duration, and playback mode.
- Save src/types.ts.
- Confirm Cursor shows no red error underline in either interface.
Seeing type errors?
Check every property name against the code above. Make sure frames uses MotionFrame[].
Need another pair of eyes? Help me fix the TypeScript interfaces in src/types.ts.
- Add the Lottie document and source asset types below AnimationRecipe by pasting this code:
export interface LottieDocument {
fr: number;
ip: number;
op: number;
w: number;
h: number;
layers: unknown[];
assets?: unknown[];
[key: string]: unknown;
}
export type SourceAsset =
| {
kind: "svg";
name: string;
width: number;
height: number;
dataUrl: string;
}
| {
kind: "lottie";
name: string;
data: LottieDocument;
};
Why use a source union?
SourceAsset gives SVG and Lottie files distinct fields under one type. The kind value lets the composition builder select the correct dimensions and source data.
- Save src/types.ts.
- Confirm Cursor still shows no red error underline in the file.
The composition builder converts recipe values into Lottie keyframes. It also places the source visual inside a precomposition so one outer transform can animate the whole asset.
Why animate an outer precomposition?
An outer precomposition gives SVG and Lottie sources the same motion layer. This keeps the adapter focused on whole-asset movement without changing internal paths, masks, or layers.
- Create src/motion.ts inside the motion-prompt-studio folder using Cursor's file explorer.
- Add the embedded sample SVG by pasting this code:
import type {
AnimationRecipe,
LottieDocument,
MotionFrame,
SourceAsset
} from "./types";
const DEFAULT_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" rx="76" fill="#111827"/>
<circle cx="160" cy="160" r="98" fill="#8B5CF6"/>
<path d="M105 170 C125 105 195 105 215 170 C192 145 128 145 105 170Z" fill="#F8FAFC"/>
<circle cx="128" cy="176" r="12" fill="#111827"/>
<circle cx="192" cy="176" r="12" fill="#111827"/>
</svg>`.trim();
What does this source contain?
DEFAULT_SVG stores the sample mark directly in the application. Its fixed dimensions give the first Lottie composition a predictable stage size.
- Save src/motion.ts.
- Confirm Cursor resolves the type import from src/types.ts without a red underline.
- Add the preset recipe and default source below DEFAULT_SVG by pasting this code:
export const presetRecipe: AnimationRecipe = {
name: "Float In",
duration: 2.4,
loop: true,
frames: [
{ at: 0, x: 0, y: 34, scale: 0.8, rotation: -8, opacity: 0 },
{ at: 0.35, x: 0, y: 0, scale: 1.08, rotation: 2, opacity: 1 },
{ at: 0.58, x: 0, y: 0, scale: 0.98, rotation: 0, opacity: 1 },
{ at: 1, x: 0, y: -10, scale: 1, rotation: 0, opacity: 1 }
]
};
export const defaultSource: SourceAsset = {
kind: "svg",
name: "sample.svg",
width: 320,
height: 320,
dataUrl: `data:image/svg+xml;base64,${btoa(DEFAULT_SVG)}`
};
How does the preset move?
- The first frame starts below the center with reduced scale, negative rotation, and zero opacity.
- The middle frames overshoot the final scale before settling.
- The final frame moves upward while preserving full opacity.
- defaultSource encodes the SVG as a data URL that Lottie can store as an image asset.
- Save src/motion.ts.
- Confirm Cursor shows no red underline under presetRecipe or defaultSource.
Lottie keyframes use frame numbers instead of the recipe's zero-to-one timeline. The next helpers translate each recipe frame into player-ready transform properties.
- Add the ID and keyframe helpers below defaultSource by pasting this code:
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
function uniqueId(base: string, ids: Set<string>) {
let candidate = base;
let suffix = 1;
while (ids.has(candidate)) {
candidate = `${base}_${suffix}`;
suffix += 1;
}
ids.add(candidate);
return candidate;
}
function propertyKeyframes(
frames: MotionFrame[],
totalFrames: number,
valueFor: (frame: MotionFrame) => number[]
) {
return {
a: 1,
k: frames.map((frame, index) => ({
t: Math.round(frame.at * (totalFrames - 1)),
s: valueFor(frame),
...(index < frames.length - 1
? { i: { x: [0.667], y: [1] }, o: { x: [0.333], y: [0] } }
: {})
}))
};
}
What do these helpers do?
- uniqueId prevents generated asset IDs from colliding with IDs that already exist in a composition.
- propertyKeyframes converts normalized timeline positions into Lottie frame numbers.
- The easing handles create smooth movement between consecutive keyframes.
- Save src/motion.ts.
- Confirm Cursor recognizes every MotionFrame reference without an error.
- Add the static transform below propertyKeyframes() by pasting this code:
function staticTransform(width: number, height: number) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: { a: 0, k: [width / 2, height / 2, 0] },
s: { a: 0, k: [100, 100, 100] },
r: { a: 0, k: 0 },
o: { a: 0, k: 100 }
};
}
Why does the source need a static transform?
The inner source layer stays centered at its natural size. The outer precomposition applies the visible motion without changing the source artwork.
- Save src/motion.ts.
- Confirm every transform property has a value in Cursor.
- Add the animated transform below staticTransform() by pasting this code:
function motionTransform(
recipe: AnimationRecipe,
width: number,
height: number,
totalFrames: number
) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
width / 2 + frame.x,
height / 2 + frame.y,
0
]),
s: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
frame.scale * 100,
frame.scale * 100,
100
]),
r: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.rotation]),
o: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.opacity * 100])
};
}
How are recipe values translated?
- Position offsets are added to the center of the composition.
- Recipe scale values are multiplied by 100 for Lottie's percentage scale.
- Opacity values are multiplied by 100 for Lottie's opacity range.
- Rotation values pass through as degrees.
- Save src/motion.ts.
- Confirm Cursor shows no error under any propertyKeyframes() call.
The final builder packages the image, source layer, precomposition, and animated outer layer into one Lottie document.
- Add buildLottie() below motionTransform() by pasting this code:
export function buildLottie(source: SourceAsset, recipe: AnimationRecipe): LottieDocument {
const width = source.kind === "svg" ? source.width : source.data.w;
const height = source.kind === "svg" ? source.height : source.data.h;
const frameRate = source.kind === "svg" ? 30 : source.data.fr;
const totalFrames = Math.max(2, Math.round(recipe.duration * frameRate));
const assets: unknown[] = source.kind === "lottie" ? structuredClone(source.data.assets ?? []) : [];
const ids = new Set<string>();
for (const asset of assets) {
if (isRecord(asset) && typeof asset.id === "string") ids.add(asset.id);
}
let sourceLayers: unknown[];
if (source.kind === "svg") {
const imageId = uniqueId("prompt_source_image", ids);
assets.push({ id: imageId, w: width, h: height, p: source.dataUrl, u: "", e: 1 });
sourceLayers = [{ ind: 1, ty: 2, nm: source.name, refId: imageId, ks: staticTransform(width, height), ao: 0, ip: 0, op: totalFrames, st: 0 }];
} else {
sourceLayers = structuredClone(source.data.layers);
}
const compositionId = uniqueId("prompt_source_comp", ids);
assets.push({ id: compositionId, layers: sourceLayers });
return { ver: 10001, nm: recipe.name, fr: frameRate, ip: 0, op: totalFrames, w: Math.round(width), h: Math.round(height), assets,
layers: [{ ind: 1, ty: 0, nm: "Prompt motion", refId: compositionId, w: Math.round(width), h: Math.round(height), sr: 1, st: 0,
ks: motionTransform(recipe, width, height, totalFrames), ao: 0, ip: 0, op: totalFrames }]
};
}
What does the builder create?
- The image asset stores the SVG data URL under a unique ID.
- The source layer references that image with a static transform.
- The precomposition groups the source layers under prompt_source_comp.
- The outer Prompt motion layer receives the animated transform keyframes.
- Save src/motion.ts.
- Confirm Cursor shows no red error badge beside src/motion.ts.
Motion file showing errors?
Check that every helper sits above buildLottie(). Confirm the import path is exactly ./types.
Still blocked? Help me debug the composition builder in src/motion.ts.
Render the Lottie animation
The generated document is plain data until a player renders it. AnimationPreview owns one player instance and destroys it whenever React replaces the preview.
- Create src/AnimationPreview.tsx inside the motion-prompt-studio folder using Cursor's file explorer.
- Add the preview component by pasting this code:
import { useEffect, useRef } from "react";
import lottie from "lottie-web";
import type { LottieDocument } from "./types";
interface AnimationPreviewProps {
data: LottieDocument;
loop: boolean;
}
export function AnimationPreview({ data, loop }: AnimationPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
return;
}
const animation = lottie.loadAnimation({
container: containerRef.current,
renderer: "svg",
loop,
autoplay: true,
animationData: structuredClone(data)
});
return () => animation.destroy();
}, [data, loop]);
return <div className="animation-canvas" ref={containerRef} />;
}
What does the preview component do?
- useRef gives lottie-web a real browser element for its SVG renderer.
- structuredClone gives the player its own copy of the composition data.
- The cleanup function destroys the old player before React creates another one.
- Save src/AnimationPreview.tsx.
- Confirm Cursor recognizes loadAnimation() and destroy() without red underlines.
Player import underlined?
Confirm the import reads lottie-web. Check that package.json still lists the installed dependency.
Need help tracing the component error? Help me debug AnimationPreview.tsx.
Assemble and test the studio
The app now needs to build the composition and pass it into the preview component. The first interface also exposes a prompt field so you can test what the preset can currently do.
- Create src/App.tsx inside the motion-prompt-studio folder using Cursor's file explorer.
- Add the initial studio interface by pasting this code:
import { useMemo, useState } from "react";
import { AnimationPreview } from "./AnimationPreview";
import { buildLottie, defaultSource, presetRecipe } from "./motion";
export default function App() {
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const animationData = useMemo(() => buildLottie(defaultSource, presetRecipe), []);
return (
<main className="app-shell">
<header className="hero"><p className="eyebrow">Prompt-driven Lottie motion</p><h1>MotionPrompt Studio</h1><p>Import one visual, describe the movement, refine it, and export a self-contained Lottie JSON file.</p></header>
<section className="studio-grid">
<aside className="panel controls-panel">
<div><span className="label">1. Source asset</span><p className="source-name">{defaultSource.name} · {defaultSource.kind.toUpperCase()}</p></div>
<div><label className="label" htmlFor="prompt">2. Motion direction</label><textarea id="prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={5} placeholder="Fade in, bounce once, then float upward" /><button>Generate Motion</button></div>
<p className="status" aria-live="polite">Preset preview is running.</p>
</aside>
<section className="panel preview-panel">
<div className="preview-heading"><div><span className="label">Live preview</span><h2>{presetRecipe.name}</h2></div><span className="duration">{presetRecipe.duration.toFixed(1)}s</span></div>
<div className="stage"><AnimationPreview data={animationData} loop={presetRecipe.loop} /></div>
</section>
<section className="panel recipe-panel">
<div className="preview-heading"><div><span className="label">Validated recipe</span><h2>{presetRecipe.frames.length} keyframes</h2></div><span className="duration">{presetRecipe.loop ? "Loop" : "Once"}</span></div>
<pre>{JSON.stringify(presetRecipe.frames, null, 2)}</pre>
</section>
</section>
</main>
);
}
How does the app build the preview?
- useMemo builds animationData once from defaultSource and presetRecipe.
- AnimationPreview receives the generated document and the recipe's loop setting.
- The prompt field stores typed text while the preview continues using presetRecipe.
- The inspector prints the four recipe frames below the stage.
- Save src/App.tsx.
- Confirm Cursor resolves the three local imports at the top of the file.
- Create src/main.tsx inside the motion-prompt-studio folder using Cursor's file explorer.
- Mount the React app by pasting this code:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
What does the entry file do?
createRoot connects React to the existing root element in index.html. StrictMode helps surface unsafe component behavior during local development.
- Save src/main.tsx.
- Confirm Cursor shows no red error badge beside any file in the src folder.
Your Express process is still using its original terminal. The Vite development server needs a second terminal because it stays active while you work.
Before you run the app, what content do you expect React to render without the stylesheet?
- Create a second terminal in Cursor.
- Start the Vite development server by running:
npm run dev
What does this command do?
The development script starts Vite and keeps the terminal occupied. Leave this process running so the browser can receive updates when files change.
- Open http://localhost:5173 in your browser.
You should see the MotionPrompt Studio heading, the prompt controls, and the four-frame recipe data. The animation area may have little or no visible size until its layout styles arrive.
Page not loading?
Confirm the Vite terminal remains active. Check Cursor for a TypeScript error that names one of the new files.
Need help interpreting the terminal output? Help me debug the first Vite render.
- Create src/styles.css inside the motion-prompt-studio folder using Cursor's file explorer.
- Add the responsive studio styles by pasting this code:
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #e5e7eb; background: #070b16; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, rgba(139, 92, 246, 0.2), transparent 32rem), #070b16; }
button, textarea, input { font: inherit; }
button { width: 100%; border: 0; border-radius: 12px; padding: 0.9rem 1rem; color: white; background: linear-gradient(135deg, #8b5cf6, #6366f1); cursor: pointer; font-weight: 700; }
button:disabled { cursor: wait; opacity: 0.65; }
.app-shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0 4rem; }
.hero { max-width: 740px; margin-bottom: 2rem; }
.hero h1 { margin: 0.25rem 0 0.75rem; font-size: clamp(2.5rem, 6vw, 4.8rem); letter-spacing: -0.06em; }
.hero p { color: #aeb8cc; line-height: 1.7; }
.eyebrow, .label { color: #a78bfa; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.studio-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 1rem; }
.panel { border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 20px; background: rgba(15, 23, 42, 0.8); box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28); backdrop-filter: blur(18px); }
.controls-panel { grid-row: span 2; display: flex; flex-direction: column; gap: 1.4rem; padding: 1.25rem; }
.source-name, .status { color: #94a3b8; font-size: 0.9rem; }
textarea { width: 100%; margin: 0.6rem 0 0.75rem; resize: vertical; border: 1px solid #334155; border-radius: 12px; padding: 0.9rem; color: #f8fafc; background: #0b1220; }
.preview-panel, .recipe-panel { padding: 1.25rem; }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.preview-heading h2 { margin: 0.3rem 0 0; }
.duration { border: 1px solid #334155; border-radius: 999px; padding: 0.45rem 0.7rem; color: #cbd5e1; background: #0b1220; }
.stage { display: grid; min-height: 430px; place-items: center; overflow: hidden; border-radius: 16px; background: linear-gradient(45deg, #111827 25%, transparent 25%), linear-gradient(-45deg, #111827 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #111827 75%), linear-gradient(-45deg, transparent 75%, #111827 75%), #0b1220; background-position: 0 0, 0 12px, 12px -12px, -12px 0; background-size: 24px 24px; }
.animation-canvas { width: min(72%, 420px); aspect-ratio: 1; }
.recipe-panel pre { max-height: 320px; overflow: auto; margin: 0; border-radius: 14px; padding: 1rem; color: #c4b5fd; background: #080d19; font-size: 0.78rem; }
@media (max-width: 860px) { .studio-grid { grid-template-columns: 1fr; } .controls-panel { grid-row: auto; } .stage { min-height: 340px; } }
What do the styles change?
- The grid separates the controls from the preview and recipe inspector.
- The checkerboard stage gives the transparent SVG a visible backdrop.
- The animation canvas receives a square display area for the SVG renderer.
- The media query stacks the panels on narrower screens.
- Save src/styles.css.
- Return to src/main.tsx in Cursor.
- Add this import below the App import:
import "./styles.css";
Why import the stylesheet here?
The entry file loads the stylesheet once for the entire application. Vite then applies style changes to the open development page.
- Save src/main.tsx.
- Return to the open development page in your browser.
You should now see a dark three-panel studio. The purple sample mark fades in, overshoots in scale, rotates, and floats upward in a loop.
Preview missing or static?
Confirm src/main.tsx imports ./styles.css. Check that AnimationPreview receives both animationData and presetRecipe.loop.
Still not moving? Help me debug the Lottie preview.
The preview now looks like a real motion tool. A useful studio also needs its written directions to affect that motion.
Before you test the prompt field, do you think changing its text will alter the current animation?
- Replace the prompt field text with Make it slower and remove the rotation.
- Click Generate Motion.
The preview still shows Float In with the same duration and keyframes. That shortfall is intentional because animationData still comes directly from presetRecipe.
What did this test prove?
The Lottie generation and rendering path works independently of AI. The prompt field currently stores text without producing a new recipe, which gives the later Claude integration a clear job.
✔️ Awesome, I've got everything!
Your files are saved. Keep both local servers running for the next step.
ⓧ I'd like to double check the full code
Compare each file below with your saved version. These references show the complete state for this step.
export interface MotionFrame {
at: number;
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
}
export interface AnimationRecipe {
name: string;
duration: number;
loop: boolean;
frames: MotionFrame[];
}
export interface LottieDocument {
fr: number;
ip: number;
op: number;
w: number;
h: number;
layers: unknown[];
assets?: unknown[];
[key: string]: unknown;
}
export type SourceAsset =
| {
kind: "svg";
name: string;
width: number;
height: number;
dataUrl: string;
}
| {
kind: "lottie";
name: string;
data: LottieDocument;
};
What to compare in the types
Check the six frame fields and both SourceAsset variants. Property spelling must match because the composition builder reads these names directly.
import type {
AnimationRecipe,
LottieDocument,
MotionFrame,
SourceAsset
} from "./types";
const DEFAULT_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" rx="76" fill="#111827"/>
<circle cx="160" cy="160" r="98" fill="#8B5CF6"/>
<path d="M105 170 C125 105 195 105 215 170 C192 145 128 145 105 170Z" fill="#F8FAFC"/>
<circle cx="128" cy="176" r="12" fill="#111827"/>
<circle cx="192" cy="176" r="12" fill="#111827"/>
</svg>`.trim();
export const presetRecipe: AnimationRecipe = {
name: "Float In",
duration: 2.4,
loop: true,
frames: [
{ at: 0, x: 0, y: 34, scale: 0.8, rotation: -8, opacity: 0 },
{ at: 0.35, x: 0, y: 0, scale: 1.08, rotation: 2, opacity: 1 },
{ at: 0.58, x: 0, y: 0, scale: 0.98, rotation: 0, opacity: 1 },
{ at: 1, x: 0, y: -10, scale: 1, rotation: 0, opacity: 1 }
]
};
export const defaultSource: SourceAsset = {
kind: "svg",
name: "sample.svg",
width: 320,
height: 320,
dataUrl: `data:image/svg+xml;base64,${btoa(DEFAULT_SVG)}`
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
function uniqueId(base: string, ids: Set<string>) {
let candidate = base;
let suffix = 1;
while (ids.has(candidate)) {
candidate = `${base}_${suffix}`;
suffix += 1;
}
ids.add(candidate);
return candidate;
}
function propertyKeyframes(
frames: MotionFrame[],
totalFrames: number,
valueFor: (frame: MotionFrame) => number[]
) {
return {
a: 1,
k: frames.map((frame, index) => ({
t: Math.round(frame.at * (totalFrames - 1)),
s: valueFor(frame),
...(index < frames.length - 1
? {
i: { x: [0.667], y: [1] },
o: { x: [0.333], y: [0] }
}
: {})
}))
};
}
function staticTransform(width: number, height: number) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: { a: 0, k: [width / 2, height / 2, 0] },
s: { a: 0, k: [100, 100, 100] },
r: { a: 0, k: 0 },
o: { a: 0, k: 100 }
};
}
function motionTransform(
recipe: AnimationRecipe,
width: number,
height: number,
totalFrames: number
) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
width / 2 + frame.x,
height / 2 + frame.y,
0
]),
s: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
frame.scale * 100,
frame.scale * 100,
100
]),
r: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.rotation]),
o: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.opacity * 100])
};
}
export function buildLottie(
source: SourceAsset,
recipe: AnimationRecipe
): LottieDocument {
const width = source.kind === "svg" ? source.width : source.data.w;
const height = source.kind === "svg" ? source.height : source.data.h;
const frameRate = source.kind === "svg" ? 30 : source.data.fr;
const totalFrames = Math.max(2, Math.round(recipe.duration * frameRate));
const assets: unknown[] =
source.kind === "lottie" ? structuredClone(source.data.assets ?? []) : [];
const ids = new Set<string>();
for (const asset of assets) {
if (isRecord(asset) && typeof asset.id === "string") {
ids.add(asset.id);
}
}
let sourceLayers: unknown[];
if (source.kind === "svg") {
const imageId = uniqueId("prompt_source_image", ids);
assets.push({
id: imageId,
w: width,
h: height,
p: source.dataUrl,
u: "",
e: 1
});
sourceLayers = [
{
ind: 1,
ty: 2,
nm: source.name,
refId: imageId,
ks: staticTransform(width, height),
ao: 0,
ip: 0,
op: totalFrames,
st: 0
}
];
} else {
sourceLayers = structuredClone(source.data.layers);
}
const compositionId = uniqueId("prompt_source_comp", ids);
assets.push({
id: compositionId,
layers: sourceLayers
});
return {
ver: 10001,
nm: recipe.name,
fr: frameRate,
ip: 0,
op: totalFrames,
w: Math.round(width),
h: Math.round(height),
assets,
layers: [
{
ind: 1,
ty: 0,
nm: "Prompt motion",
refId: compositionId,
w: Math.round(width),
h: Math.round(height),
sr: 1,
st: 0,
ks: motionTransform(recipe, width, height, totalFrames),
ao: 0,
ip: 0,
op: totalFrames
}
]
};
}
What to compare in the composition builder
Check the Float In values, the two generated asset IDs, and every transform property. These values determine the motion visible on the stage.
import { useEffect, useRef } from "react";
import lottie from "lottie-web";
import type { LottieDocument } from "./types";
interface AnimationPreviewProps {
data: LottieDocument;
loop: boolean;
}
export function AnimationPreview({ data, loop }: AnimationPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) {
return;
}
const animation = lottie.loadAnimation({
container: containerRef.current,
renderer: "svg",
loop,
autoplay: true,
animationData: structuredClone(data)
});
return () => animation.destroy();
}, [data, loop]);
return <div className="animation-canvas" ref={containerRef} />;
}
What to compare in the player
Check the SVG renderer, autoplay setting, cloned animation data, and cleanup return value. Together they keep each player instance isolated.
import { useMemo, useState } from "react";
import { AnimationPreview } from "./AnimationPreview";
import { buildLottie, defaultSource, presetRecipe } from "./motion";
export default function App() {
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const animationData = useMemo(
() => buildLottie(defaultSource, presetRecipe),
[]
);
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">Prompt-driven Lottie motion</p>
<h1>MotionPrompt Studio</h1>
<p>
Import one visual, describe the movement, refine it, and export a
self-contained Lottie JSON file.
</p>
</header>
<section className="studio-grid">
<aside className="panel controls-panel">
<div>
<span className="label">1. Source asset</span>
<p className="source-name">
{defaultSource.name} · {defaultSource.kind.toUpperCase()}
</p>
</div>
<div>
<label className="label" htmlFor="prompt">
2. Motion direction
</label>
<textarea
id="prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={5}
placeholder="Fade in, bounce once, then float upward"
/>
<button>Generate Motion</button>
</div>
<p className="status" aria-live="polite">
Preset preview is running.
</p>
</aside>
<section className="panel preview-panel">
<div className="preview-heading">
<div>
<span className="label">Live preview</span>
<h2>{presetRecipe.name}</h2>
</div>
<span className="duration">{presetRecipe.duration.toFixed(1)}s</span>
</div>
<div className="stage">
<AnimationPreview data={animationData} loop={presetRecipe.loop} />
</div>
</section>
<section className="panel recipe-panel">
<div className="preview-heading">
<div>
<span className="label">Validated recipe</span>
<h2>{presetRecipe.frames.length} keyframes</h2>
</div>
<span className="duration">
{presetRecipe.loop ? "Loop" : "Once"}
</span>
</div>
<pre>{JSON.stringify(presetRecipe.frames, null, 2)}</pre>
</section>
</section>
</main>
);
}
What to compare in the interface
Check that animationData always uses defaultSource and presetRecipe. The prompt state appears in the textarea without entering the composition builder.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
What to compare in the entry file
Check that the stylesheet import appears before createRoot mounts App into the existing root element.
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
color: #e5e7eb;
background: #070b16;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(139, 92, 246, 0.2), transparent 32rem),
#070b16;
}
button,
textarea,
input {
font: inherit;
}
button {
width: 100%;
border: 0;
border-radius: 12px;
padding: 0.9rem 1rem;
color: white;
background: linear-gradient(135deg, #8b5cf6, #6366f1);
cursor: pointer;
font-weight: 700;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.app-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 4rem;
}
.hero {
max-width: 740px;
margin-bottom: 2rem;
}
.hero h1 {
margin: 0.25rem 0 0.75rem;
font-size: clamp(2.5rem, 6vw, 4.8rem);
letter-spacing: -0.06em;
}
.hero p {
color: #aeb8cc;
line-height: 1.7;
}
.eyebrow,
.label {
color: #a78bfa;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.studio-grid {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 1rem;
}
.panel {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: rgba(15, 23, 42, 0.8);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.controls-panel {
grid-row: span 2;
display: flex;
flex-direction: column;
gap: 1.4rem;
padding: 1.25rem;
}
.source-name,
.status {
color: #94a3b8;
font-size: 0.9rem;
}
textarea {
width: 100%;
margin: 0.6rem 0 0.75rem;
resize: vertical;
border: 1px solid #334155;
border-radius: 12px;
padding: 0.9rem;
color: #f8fafc;
background: #0b1220;
}
.preview-panel,
.recipe-panel {
padding: 1.25rem;
}
.preview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.preview-heading h2 {
margin: 0.3rem 0 0;
}
.duration {
border: 1px solid #334155;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: #cbd5e1;
background: #0b1220;
}
.stage {
display: grid;
min-height: 430px;
place-items: center;
overflow: hidden;
border-radius: 16px;
background:
linear-gradient(45deg, #111827 25%, transparent 25%),
linear-gradient(-45deg, #111827 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #111827 75%),
linear-gradient(-45deg, transparent 75%, #111827 75%),
#0b1220;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
background-size: 24px 24px;
}
.animation-canvas {
width: min(72%, 420px);
aspect-ratio: 1;
}
.recipe-panel pre {
max-height: 320px;
overflow: auto;
margin: 0;
border-radius: 14px;
padding: 1rem;
color: #c4b5fd;
background: #080d19;
font-size: 0.78rem;
}
@media (max-width: 860px) {
.studio-grid {
grid-template-columns: 1fr;
}
.controls-panel {
grid-row: auto;
}
.stage {
min-height: 340px;
}
}
What to compare in the stylesheet
Check the stage dimensions, animation canvas size, panel grid, and mobile media query. These rules make the rendered animation visible across screen sizes.
That is the first visible motion loop working. Next, you will replace the bundled-only experience with SVG and Lottie file imports.
Import SVG and Lottie Assets
Your bundled sample now moves through a looping Lottie preview. That proves the animation generator and renderer work together.
The studio still accepts only its bundled visual. This step adds an importer that normalizes real SVG and Lottie files into the same animated outer composition.
In this step, get ready to:
- Validate SVG and Lottie JSON files before using their data.
- Wrap either source format in one animated precomposition.
- Import a file through the studio while preserving the live preview.
Prepare safe SVG and Lottie inputs
An SVG stores scalable artwork as markup. A Lottie file stores animation data as JSON. The importer needs separate checks for each format before either one reaches the preview.
- In the Explorer sidebar in Cursor, create a file named sample.svg inside a new public folder.
- Add the reusable sample artwork to public/sample.svg by pasting this code:
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" rx="76" fill="#111827"/>
<circle cx="160" cy="160" r="98" fill="#8B5CF6"/>
<path d="M105 170 C125 105 195 105 215 170 C192 145 128 145 105 170Z" fill="#F8FAFC"/>
<circle cx="128" cy="176" r="12" fill="#111827"/>
<circle cx="192" cy="176" r="12" fill="#111827"/>
</svg>
What Does This Artwork Contain?
- The outer rectangle provides the dark rounded background.
- The purple circle provides the main mark that stays easy to recognize while moving.
- The fixed dimensions and matching view box give the importer a reliable 320 by 320 canvas.
- Save public/sample.svg.
- Visit http://localhost:5173/sample.svg in your browser to verify the file is available.
You should see the purple sample mark on its dark rounded background. The development server can now serve this file to the importer.
Sample File Not Loading?
Confirm that sample.svg sits inside public within the motion-prompt-studio folder. Check that the filename uses lowercase letters.
If the browser shows malformed artwork, compare every closing tag with the code above.
Ask for help with the file path or SVG markup.
Imported data comes from outside your application. Small type guards let the parser confirm the minimum fields it needs before treating unknown JSON as a Lottie document.
- In src/motion.ts, find the defaultSource object.
- Add these validation helpers directly below defaultSource:
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const finiteNumber = (value: unknown): value is number =>
typeof value === "number" && Number.isFinite(value);
function isLottieDocument(value: unknown): value is LottieDocument {
return (
isRecord(value) &&
finiteNumber(value.fr) &&
finiteNumber(value.ip) &&
finiteNumber(value.op) &&
finiteNumber(value.w) &&
finiteNumber(value.h) &&
Array.isArray(value.layers) &&
(value.assets === undefined || Array.isArray(value.assets))
);
}
What Do These Checks Protect?
- The isRecord() guard rejects null values and primitive values before property access.
- The finiteNumber() guard rejects missing dimensions and invalid numeric values.
- The isLottieDocument() guard requires the composition timing, dimensions, layers, and valid optional assets used by the wrapper.
- Add the file-reading helpers directly below isLottieDocument():
const readText = (file: File) => file.text();
const readDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
typeof reader.result === "string"
? resolve(reader.result)
: reject(new Error("The SVG could not be encoded."));
reader.onerror = () => reject(reader.error ?? new Error("File reading failed."));
reader.readAsDataURL(file);
});
Why Read SVG Files Twice?
The text version exposes the SVG dimensions for validation. The data URL version safely embeds the complete image inside the exported Lottie asset.
- Save src/motion.ts.
- Check the Vite terminal from earlier for a successful update.
Your browser should continue showing the looping sample without a compilation error. The new helpers are valid inside the existing preview pipeline.
- Add the SVG dimension reader directly below readDataUrl:
function svgDimensions(source: string) {
const documentNode = new DOMParser().parseFromString(source, "image/svg+xml");
const svg = documentNode.documentElement;
if (svg.nodeName.toLowerCase() !== "svg") {
throw new Error("The selected file is not valid SVG.");
}
const viewBox = svg
.getAttribute("viewBox")
?.trim()
.split(/[ ,]+/)
.map(Number);
const width = Number.parseFloat(svg.getAttribute("width") ?? "") || viewBox?.[2] || 512;
const height = Number.parseFloat(svg.getAttribute("height") ?? "") || viewBox?.[3] || 512;
return { width, height };
}
How Are SVG Dimensions Chosen?
- Explicit width and height attributes take priority when they contain usable numbers.
- The third and fourth viewBox values provide dimensions when explicit attributes are absent.
- A fallback of 512 keeps valid SVG artwork usable when neither source supplies a dimension.
Wrap both formats in one composition
A precomposition groups source layers behind one outer Lottie layer. The studio can animate that outer layer without rewriting the internal paths, masks, text, or transforms of an imported file.
Why Use an Outer Precomposition?
SVG images and authored Lottie files have different internal structures. One outer motion layer gives both formats the same position, scale, rotation, and opacity controls.
This adapter pattern preserves the imported visual while keeping motion generation predictable.
- In src/motion.ts, add parseAsset() directly below svgDimensions() by matching the full-file reference below.
- Replace the existing buildLottie() implementation with the version in the same reference.
✔️ Awesome, I've got everything!
Save src/motion.ts once your importer and shared outer composition match the reference.
ⓧ I'd like to double check the full code
import type {
AnimationRecipe,
LottieDocument,
MotionFrame,
SourceAsset
} from "./types";
const DEFAULT_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" rx="76" fill="#111827"/>
<circle cx="160" cy="160" r="98" fill="#8B5CF6"/>
<path d="M105 170 C125 105 195 105 215 170 C192 145 128 145 105 170Z" fill="#F8FAFC"/>
<circle cx="128" cy="176" r="12" fill="#111827"/>
<circle cx="192" cy="176" r="12" fill="#111827"/>
</svg>`.trim();
export const presetRecipe: AnimationRecipe = {
name: "Float In",
duration: 2.4,
loop: true,
frames: [
{ at: 0, x: 0, y: 34, scale: 0.8, rotation: -8, opacity: 0 },
{ at: 0.35, x: 0, y: 0, scale: 1.08, rotation: 2, opacity: 1 },
{ at: 0.58, x: 0, y: 0, scale: 0.98, rotation: 0, opacity: 1 },
{ at: 1, x: 0, y: -10, scale: 1, rotation: 0, opacity: 1 }
]
};
export const defaultSource: SourceAsset = {
kind: "svg",
name: "sample.svg",
width: 320,
height: 320,
dataUrl: `data:image/svg+xml;base64,${btoa(DEFAULT_SVG)}`
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const finiteNumber = (value: unknown): value is number =>
typeof value === "number" && Number.isFinite(value);
function isLottieDocument(value: unknown): value is LottieDocument {
return (
isRecord(value) &&
finiteNumber(value.fr) &&
finiteNumber(value.ip) &&
finiteNumber(value.op) &&
finiteNumber(value.w) &&
finiteNumber(value.h) &&
Array.isArray(value.layers) &&
(value.assets === undefined || Array.isArray(value.assets))
);
}
const readText = (file: File) => file.text();
const readDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
typeof reader.result === "string"
? resolve(reader.result)
: reject(new Error("The SVG could not be encoded."));
reader.onerror = () => reject(reader.error ?? new Error("File reading failed."));
reader.readAsDataURL(file);
});
function svgDimensions(source: string) {
const documentNode = new DOMParser().parseFromString(source, "image/svg+xml");
const svg = documentNode.documentElement;
if (svg.nodeName.toLowerCase() !== "svg") {
throw new Error("The selected file is not valid SVG.");
}
const viewBox = svg
.getAttribute("viewBox")
?.trim()
.split(/[ ,]+/)
.map(Number);
const width = Number.parseFloat(svg.getAttribute("width") ?? "") || viewBox?.[2] || 512;
const height = Number.parseFloat(svg.getAttribute("height") ?? "") || viewBox?.[3] || 512;
return { width, height };
}
export async function parseAsset(file: File): Promise<SourceAsset> {
const text = await readText(file);
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (isSvg) {
const { width, height } = svgDimensions(text);
return {
kind: "svg",
name: file.name,
width,
height,
dataUrl: await readDataUrl(file)
};
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("The selected JSON file could not be parsed.");
}
if (!isLottieDocument(parsed)) {
throw new Error("The JSON is missing required Lottie composition fields.");
}
return {
kind: "lottie",
name: file.name,
data: parsed
};
}
function uniqueId(base: string, ids: Set<string>) {
let candidate = base;
let suffix = 1;
while (ids.has(candidate)) {
candidate = `${base}_${suffix}`;
suffix += 1;
}
ids.add(candidate);
return candidate;
}
function propertyKeyframes(
frames: MotionFrame[],
totalFrames: number,
valueFor: (frame: MotionFrame) => number[]
) {
return {
a: 1,
k: frames.map((frame, index) => ({
t: Math.round(frame.at * (totalFrames - 1)),
s: valueFor(frame),
...(index < frames.length - 1
? {
i: { x: [0.667], y: [1] },
o: { x: [0.333], y: [0] }
}
: {})
}))
};
}
function staticTransform(width: number, height: number) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: { a: 0, k: [width / 2, height / 2, 0] },
s: { a: 0, k: [100, 100, 100] },
r: { a: 0, k: 0 },
o: { a: 0, k: 100 }
};
}
function motionTransform(
recipe: AnimationRecipe,
width: number,
height: number,
totalFrames: number
) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
width / 2 + frame.x,
height / 2 + frame.y,
0
]),
s: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
frame.scale * 100,
frame.scale * 100,
100
]),
r: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.rotation]),
o: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.opacity * 100])
};
}
export function buildLottie(
source: SourceAsset,
recipe: AnimationRecipe
): LottieDocument {
const width = source.kind === "svg" ? source.width : source.data.w;
const height = source.kind === "svg" ? source.height : source.data.h;
const frameRate = source.kind === "svg" ? 30 : source.data.fr;
const totalFrames = Math.max(2, Math.round(recipe.duration * frameRate));
const assets: unknown[] =
source.kind === "lottie" ? structuredClone(source.data.assets ?? []) : [];
const ids = new Set<string>();
for (const asset of assets) {
if (isRecord(asset) && typeof asset.id === "string") {
ids.add(asset.id);
}
}
let sourceLayers: unknown[];
if (source.kind === "svg") {
const imageId = uniqueId("prompt_source_image", ids);
assets.push({
id: imageId,
w: width,
h: height,
p: source.dataUrl,
u: "",
e: 1
});
sourceLayers = [
{
ind: 1,
ty: 2,
nm: source.name,
refId: imageId,
ks: staticTransform(width, height),
ao: 0,
ip: 0,
op: totalFrames,
st: 0
}
];
} else {
sourceLayers = structuredClone(source.data.layers);
}
const compositionId = uniqueId("prompt_source_comp", ids);
assets.push({
id: compositionId,
layers: sourceLayers
});
return {
ver: 10001,
nm: recipe.name,
fr: frameRate,
ip: 0,
op: totalFrames,
w: Math.round(width),
h: Math.round(height),
assets,
layers: [
{
ind: 1,
ty: 0,
nm: "Prompt motion",
refId: compositionId,
w: Math.round(width),
h: Math.round(height),
sr: 1,
st: 0,
ks: motionTransform(recipe, width, height, totalFrames),
ao: 0,
ip: 0,
op: totalFrames
}
]
};
}
How Does the Shared Wrapper Work?
- The parseAsset() function returns one SourceAsset shape for either supported input.
- SVG artwork becomes an embedded image asset with a data URL.
- Lottie layers and assets are cloned before the wrapper uses them.
- The uniqueId() helper prevents the wrapper from overwriting an imported asset that already uses one of its preferred identifiers.
- Save src/motion.ts.
- Refresh the studio at http://localhost:5173.
You should still see the purple mark fading, scaling, rotating, and floating. The existing bundled source now passes through the same wrapper that imported files use.
Preview Disappeared After the Wrapper Change?
Confirm that buildLottie() uses source.kind before reading format-specific fields. Check that prompt_source_comp is added to assets before the outer layer references it.
Use the full-file reference to check any missing brace or property.
Ask for help tracing the wrapper data.
Connect the file picker
The parser and wrapper now support external files. React needs to keep the selected source in state so each successful import rebuilds the memoized animation data.
- In src/App.tsx, replace the existing import area with these imports:
import { useMemo, useState, type ChangeEvent } from "react";
import { AnimationPreview } from "./AnimationPreview";
import {
buildLottie,
defaultSource,
parseAsset,
presetRecipe
} from "./motion";
import type { AnimationRecipe, SourceAsset } from "./types";
What Do the New Imports Add?
- The ChangeEvent type describes the file input event.
- The parseAsset() function turns the selected browser file into a validated source.
- The SourceAsset type keeps SVG and Lottie state safe inside the component.
- At the top of App(), replace the current state and animation setup with this code:
const [source, setSource] = useState<SourceAsset>(defaultSource);
const [recipe, setRecipe] = useState<AnimationRecipe>(presetRecipe);
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const [status, setStatus] = useState("Preset preview is running.");
const [loading, setLoading] = useState(false);
const animationData = useMemo(
() => buildLottie(source, recipe),
[source, recipe]
);
Why Store the Source in State?
Calling setSource() gives React a new source value after an import. The useMemo() dependency list then rebuilds the Lottie composition from that source and the current recipe.
- Save src/App.tsx.
- Confirm the running browser still shows the preset animation.
The preview should continue looping with the same Float In recipe. Its source now lives in replaceable component state.
- Add handleFileChange() below the animationData calculation:
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setStatus(`Importing ${file.name}...`);
const imported = await parseAsset(file);
setSource(imported);
setStatus(`${file.name} is ready.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Import failed.");
} finally {
event.target.value = "";
}
}
What Happens During an Import?
- The handler ignores an empty selection before starting any file work.
- A successful parse updates the source and reports the selected filename.
- A rejected file leaves the current animation intact while its validation message appears in the status area.
- Clearing the input allows you to select the same file again after every attempt.
- In the source asset area of src/App.tsx, replace the static source display with this file control:
<label className="file-control">
<span>Choose SVG or Lottie JSON</span>
<input
type="file"
accept=".svg,.json,image/svg+xml,application/json"
onChange={handleFileChange}
/>
</label>
<p className="source-name">
{source.name} · {source.kind.toUpperCase()}
</p>
How Does the Picker Limit Inputs?
The accept value guides the browser toward SVG and JSON files. The parser still validates the selected contents because a filename or media type alone cannot prove that a file is compatible.
- Save src/App.tsx.
- Return to the studio in your browser.
You should now see a file picker labeled Choose SVG or Lottie JSON above the current source name.
✔️ Awesome, I've got everything!
Your picker is visible. Save src/App.tsx before testing the sample import.
ⓧ I'd like to double check the full code
import { useMemo, useState, type ChangeEvent } from "react";
import { AnimationPreview } from "./AnimationPreview";
import {
buildLottie,
defaultSource,
parseAsset,
presetRecipe
} from "./motion";
import type { AnimationRecipe, SourceAsset } from "./types";
export default function App() {
const [source, setSource] = useState<SourceAsset>(defaultSource);
const [recipe, setRecipe] = useState<AnimationRecipe>(presetRecipe);
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const [status, setStatus] = useState("Preset preview is running.");
const [loading, setLoading] = useState(false);
const animationData = useMemo(
() => buildLottie(source, recipe),
[source, recipe]
);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setStatus(`Importing ${file.name}...`);
const imported = await parseAsset(file);
setSource(imported);
setStatus(`${file.name} is ready.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Import failed.");
} finally {
event.target.value = "";
}
}
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">Prompt-driven Lottie motion</p>
<h1>MotionPrompt Studio</h1>
<p>
Import one visual, describe the movement, refine it, and export a
self-contained Lottie JSON file.
</p>
</header>
<section className="studio-grid">
<aside className="panel controls-panel">
<div>
<span className="label">1. Source asset</span>
<label className="file-control">
<span>Choose SVG or Lottie JSON</span>
<input
type="file"
accept=".svg,.json,image/svg+xml,application/json"
onChange={handleFileChange}
/>
</label>
<p className="source-name">
{source.name} · {source.kind.toUpperCase()}
</p>
</div>
<div>
<label className="label" htmlFor="prompt">
2. Motion direction
</label>
<textarea
id="prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={5}
placeholder="Fade in, bounce once, then float upward"
/>
<button disabled={loading}>
{loading ? "Designing..." : "Generate Motion"}
</button>
</div>
<p className="status" aria-live="polite">
{status}
</p>
</aside>
<section className="panel preview-panel">
<div className="preview-heading">
<div>
<span className="label">Live preview</span>
<h2>{recipe.name}</h2>
</div>
<span className="duration">{recipe.duration.toFixed(1)}s</span>
</div>
<div className="stage">
<AnimationPreview data={animationData} loop={recipe.loop} />
</div>
</section>
<section className="panel recipe-panel">
<div className="preview-heading">
<div>
<span className="label">Validated recipe</span>
<h2>{recipe.frames.length} keyframes</h2>
</div>
<span className="duration">{recipe.loop ? "Loop" : "Once"}</span>
</div>
<pre>{JSON.stringify(recipe.frames, null, 2)}</pre>
</section>
</section>
</main>
);
}
Before you choose the sample file, do you expect the imported source to keep the same motion or replace it with a different recipe?
- Click Choose SVG or Lottie JSON in the source asset panel.
- Select public/sample.svg from the motion-prompt-studio folder.
You should see sample.svg · SVG below the picker. The status should read sample.svg is ready..
The purple mark should continue fading, scaling, rotating, and floating. The import changes the source while preserving the hard-coded recipe.
- If you have a compatible Lottie JSON file, select it through the same picker.
A compatible file should replace the source label with its filename. Its grouped visual should play with the same outer motion.
Where Is the Compatibility Boundary?
The studio animates each imported file as one grouped visual. It preserves compatible source layers behind prompt_source_comp.
This importer does not edit arbitrary internal paths, masks, text, expressions, or individual After Effects layers. Whole-asset motion keeps the adapter reliable across both supported input formats.
Import Rejected or Preview Missing?
For an SVG file, confirm that its root element is svg. Check that it has usable dimensions or a viewBox.
For a JSON file, confirm that the root includes numeric timing and dimensions. It also needs a layers array.
Ask for help classifying the rejected file.
Your studio now accepts real SVG and compatible Lottie files without exposing their internal structures to the motion controls. Next, you will replace the fixed recipe with validated motion generated from your prompts.
Turn Prompts into Validated Motion
Your imported asset now plays inside a Lottie composition. The prompt field still leaves the recipe unchanged.
Free-form model text could also break the animation pipeline. This step connects Claude through Express. A Zod schema keeps every returned motion recipe within the structure your app expects.
In this step, get ready to:
- Replace the health-only server with a validated motion recipe endpoint.
- Connect the prompt composer to the server through the existing Vite proxy.
- Generate an initial motion before refining it with a follow-up prompt.
Build the validated recipe endpoint
The server acts as the boundary between your browser and Claude. It validates incoming requests before sending the prompt to the model.
The returned recipe passes through schema validation first. Numeric normalization then limits values that could create unusable animation data.
- Switch back to server/index.ts in Cursor.
- Select all existing code in server/index.ts.
- Replace the selected code with the imports plus schemas below:
import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import express from "express";
import { z } from "zod";
const frameSchema = z.object({
at: z.number(),
x: z.number(),
y: z.number(),
scale: z.number(),
rotation: z.number(),
opacity: z.number()
});
const recipeSchema = z.object({
name: z.string(),
duration: z.number(),
loop: z.boolean(),
frames: z.array(frameSchema)
});
const requestSchema = z.object({
prompt: z.string(),
currentRecipe: recipeSchema.optional()
});
type Recipe = z.infer<typeof recipeSchema>;
What do these schemas protect?
- The frame schema requires timing plus position, scale, rotation, and opacity values.
- The recipe schema requires a name, duration, loop setting, and frame list.
- The request schema accepts a prompt plus an optional current recipe for follow-up refinements.
- Append the Express setup plus health route directly below the Recipe type:
const app = express();
const port = 8787;
const anthropic = process.env.ANTHROPIC_API_KEY ? new Anthropic() : null;
app.use(express.json({ limit: "256kb" }));
app.get("/api/health", (_request, response) => {
response.json({
ok: true,
claudeConfigured: Boolean(anthropic)
});
});
app.listen(port, () => {
console.log(`MotionPrompt server listening on http://localhost:${port}`);
});
How does the server keep the key private?
The Anthropic client is created inside the server process. The browser receives only a Boolean configuration status from the health route.
The JSON size limit also places a boundary around browser requests. This keeps the API surface focused on compact motion recipes.
- Save server/index.ts.
Prediction time: will the existing health route survive the server rewrite? Your next check reveals the answer.
- Confirm the health route from the terminal by running:
curl http://localhost:8787/api/health
What does this check prove?
This request proves that the rewritten Express server is listening on the same port. It also confirms that the server process can detect your local credential.
You should see a response containing "ok":true plus "claudeConfigured":true.
- Insert the normalization helpers between the Recipe type and const app = express();.
const clamp = (value: number, minimum: number, maximum: number) =>
Math.min(maximum, Math.max(minimum, value));
function normalizeRecipe(recipe: Recipe): Recipe {
if (recipe.frames.length < 2) {
throw new Error("Claude returned fewer than two motion frames.");
}
const frames = recipe.frames
.slice(0, 6)
.map((frame) => ({
at: clamp(frame.at, 0, 1),
x: clamp(frame.x, -600, 600),
y: clamp(frame.y, -600, 600),
scale: clamp(frame.scale, 0.05, 4),
rotation: clamp(frame.rotation, -720, 720),
opacity: clamp(frame.opacity, 0, 1)
}))
.sort((left, right) => left.at - right.at);
frames[0].at = 0;
frames[frames.length - 1].at = 1;
return {
name: recipe.name.trim() || "Prompt Motion",
duration: clamp(recipe.duration, 0.5, 8),
loop: recipe.loop,
frames
};
}
What does normalization add?
- The frame count stays between two and six because the function rejects shorter recipes before keeping at most six frames.
- Each motion property stays inside a practical numeric range.
- Sorting places frames in timeline order before the first and last timing values are fixed.
The endpoint is one continuous route. Paste the next three sections directly above app.listen(port, () => { before saving the file.
- Add the request guard plus validated request extraction with this first section:
app.post("/api/recipe", async (request, response) => {
if (!anthropic) {
response.status(500).json({ error: "ANTHROPIC_API_KEY is not configured." });
return;
}
const parsedRequest = requestSchema.safeParse(request.body);
if (!parsedRequest.success) {
response.status(400).json({ error: "Send a prompt and an optional valid current recipe." });
return;
}
const { prompt, currentRecipe } = parsedRequest.data;
try {
What does the request guard do?
The route stops early when the server has no Anthropic client. It also rejects browser payloads that do not match the request schema.
- Continue the same route with the structured Claude request below:
const message = await anthropic.messages.parse({
model: "claude-haiku-5-5",
max_tokens: 800,
system:
"You are a motion designer. Convert the request into a compact whole-asset motion recipe. " +
"Use 3 to 6 frames. The first frame must use at=0 and the last must use at=1. " +
"x and y are pixel offsets, scale uses 1 as 100 percent, rotation is degrees, and opacity is 0 to 1. " +
"When a current recipe is supplied, treat the request as a refinement unless the user asks to replace it.",
messages: [
{
role: "user",
content: `Motion request: ${prompt}\nCurrent recipe: ${JSON.stringify(currentRecipe ?? null)}`
}
],
output_config: {
format: zodOutputFormat(recipeSchema)
}
});
How does structured output work here?
- The system instruction defines the meaning of every motion property.
- The current recipe gives Claude the context needed to refine existing motion.
- The output format constrains the response to the recipe schema before your application reads it.
- Finish the route with refusal handling plus normalized response handling:
if (message.stop_reason === "refusal") {
response.status(422).json({ error: "Claude declined this motion request." });
return;
}
if (!message.parsed_output) {
response.status(502).json({ error: "Claude did not return a motion recipe." });
return;
}
response.json({ recipe: normalizeRecipe(message.parsed_output) });
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown server error";
response.status(500).json({ error: `Motion generation failed: ${detail}` });
}
});
How does the route finish safely?
A refusal or absent parsed output becomes a clear HTTP error response. Successful output passes through normalizeRecipe() before reaching the browser.
The catch block turns unexpected failures into a response the prompt composer can display. The server remains the only process that communicates with Claude.
- Save server/index.ts.
- Check the server terminal for MotionPrompt server listening on http://localhost:8787.
✔️ Awesome, I've got everything!
Your server now accepts validated recipe requests. Keep the server process running for the browser test.
ⓧ I'd like to double check the full code
import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import express from "express";
import { z } from "zod";
const frameSchema = z.object({
at: z.number(),
x: z.number(),
y: z.number(),
scale: z.number(),
rotation: z.number(),
opacity: z.number()
});
const recipeSchema = z.object({
name: z.string(),
duration: z.number(),
loop: z.boolean(),
frames: z.array(frameSchema)
});
const requestSchema = z.object({
prompt: z.string(),
currentRecipe: recipeSchema.optional()
});
type Recipe = z.infer<typeof recipeSchema>;
const clamp = (value: number, minimum: number, maximum: number) =>
Math.min(maximum, Math.max(minimum, value));
function normalizeRecipe(recipe: Recipe): Recipe {
if (recipe.frames.length < 2) {
throw new Error("Claude returned fewer than two motion frames.");
}
const frames = recipe.frames
.slice(0, 6)
.map((frame) => ({
at: clamp(frame.at, 0, 1),
x: clamp(frame.x, -600, 600),
y: clamp(frame.y, -600, 600),
scale: clamp(frame.scale, 0.05, 4),
rotation: clamp(frame.rotation, -720, 720),
opacity: clamp(frame.opacity, 0, 1)
}))
.sort((left, right) => left.at - right.at);
frames[0].at = 0;
frames[frames.length - 1].at = 1;
return {
name: recipe.name.trim() || "Prompt Motion",
duration: clamp(recipe.duration, 0.5, 8),
loop: recipe.loop,
frames
};
}
const app = express();
const port = 8787;
const anthropic = process.env.ANTHROPIC_API_KEY ? new Anthropic() : null;
app.use(express.json({ limit: "256kb" }));
app.get("/api/health", (_request, response) => {
response.json({
ok: true,
claudeConfigured: Boolean(anthropic)
});
});
app.post("/api/recipe", async (request, response) => {
if (!anthropic) {
response.status(500).json({ error: "ANTHROPIC_API_KEY is not configured." });
return;
}
const parsedRequest = requestSchema.safeParse(request.body);
if (!parsedRequest.success) {
response.status(400).json({ error: "Send a prompt and an optional valid current recipe." });
return;
}
const { prompt, currentRecipe } = parsedRequest.data;
try {
const message = await anthropic.messages.parse({
model: "claude-haiku-5-5",
max_tokens: 800,
system:
"You are a motion designer. Convert the request into a compact whole-asset motion recipe. " +
"Use 3 to 6 frames. The first frame must use at=0 and the last must use at=1. " +
"x and y are pixel offsets, scale uses 1 as 100 percent, rotation is degrees, and opacity is 0 to 1. " +
"When a current recipe is supplied, treat the request as a refinement unless the user asks to replace it.",
messages: [
{
role: "user",
content: `Motion request: ${prompt}\nCurrent recipe: ${JSON.stringify(currentRecipe ?? null)}`
}
],
output_config: {
format: zodOutputFormat(recipeSchema)
}
});
if (message.stop_reason === "refusal") {
response.status(422).json({ error: "Claude declined this motion request." });
return;
}
if (!message.parsed_output) {
response.status(502).json({ error: "Claude did not return a motion recipe." });
return;
}
response.json({ recipe: normalizeRecipe(message.parsed_output) });
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown server error";
response.status(500).json({ error: `Motion generation failed: ${detail}` });
}
});
app.listen(port, () => {
console.log(`MotionPrompt server listening on http://localhost:${port}`);
});
What should match?
Your file should contain both API routes. The recipe route should sit above the final server listener.
Server stopped after saving?
Check that the three recipe route sections appear together above app.listen. A missing brace can prevent the watch process from restarting.
Confirm that your local .env file still contains the credential expected by ANTHROPIC_API_KEY.
Compare your file with the full reference before retrying the health request.
Help me debug my validated Express recipe endpoint.
Connect the prompt composer
The existing Vite proxy already forwards browser requests under /api to the local server. The browser now needs a function that sends the prompt plus the current recipe.
Keeping the current recipe in each request gives follow-up prompts useful context. A successful response replaces the local recipe immediately.
- Switch back to src/App.tsx in Cursor.
- Add this response shape below the existing imports:
interface RecipeResponse {
recipe?: AnimationRecipe;
error?: string;
}
What does this type represent?
A successful response contains a validated recipe. A failed response can contain an error message for the status area.
- Update the state declarations so they include prompt history:
const [source, setSource] = useState<SourceAsset>(defaultSource);
const [recipe, setRecipe] = useState<AnimationRecipe>(presetRecipe);
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const [history, setHistory] = useState<string[]>([]);
const [status, setStatus] = useState("Preset preview is running.");
const [loading, setLoading] = useState(false);
Why keep prompt history?
The history records each successful direction in order. It gives you a visible trail of the initial request plus later refinements.
The request function is one continuous function. Add the next two sections below handleFileChange() before saving the file.
- Add the prompt check plus server request with this first section:
async function generateMotion() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
setStatus("Write a motion request first.");
return;
}
setLoading(true);
setStatus("Claude is designing the motion...");
try {
const response = await fetch("/api/recipe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: trimmedPrompt,
currentRecipe: recipe
})
});
const payload = (await response.json()) as RecipeResponse;
if (!response.ok || !payload.recipe) {
throw new Error(payload.error ?? "Motion generation failed.");
}
What does the browser send?
The request contains the trimmed prompt plus the recipe currently driving the preview. No credential enters the request body.
The loading state also prevents repeated clicks while one request is in progress. The status area tells you when Claude is designing the motion.
- Finish the function with the success plus error state updates:
setRecipe(payload.recipe);
setHistory((current) => [...current, trimmedPrompt]);
setStatus(`Applied ${payload.recipe.name}.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Motion generation failed.");
} finally {
setLoading(false);
}
}
How does the preview update?
A valid response replaces the recipe state. The existing memoized Lottie composition rebuilds from that new recipe.
Failures leave the previous motion intact. The status message exposes the server error without breaking the preview.
- Update the prompt button so it calls generateMotion() with this block:
<button onClick={generateMotion} disabled={loading}>
{loading ? "Designing..." : "Generate Motion"}
</button>
What changes during a request?
The button starts the recipe request. Its label reflects the loading state while the control remains disabled.
- Add the refinement history directly below the status paragraph:
{history.length > 0 && (
<div>
<span className="label">Refinement history</span>
<ol className="history-list">
{history.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ol>
</div>
)}
When does history appear?
The list stays hidden until the first successful request. Each later success adds the submitted direction beneath the earlier prompts.
- Save src/App.tsx.
- Return to the development app at http://localhost:5173.
The page should still show the imported source plus the live preset preview. The prompt button should now read Generate Motion.
✔️ Awesome, I've got everything!
Your prompt composer is connected. The next request can replace the hard-coded recipe.
ⓧ I'd like to double check the full code
import { useMemo, useState, type ChangeEvent } from "react";
import { AnimationPreview } from "./AnimationPreview";
import {
buildLottie,
defaultSource,
downloadLottie,
parseAsset,
presetRecipe
} from "./motion";
import type { AnimationRecipe, SourceAsset } from "./types";
interface RecipeResponse {
recipe?: AnimationRecipe;
error?: string;
}
export default function App() {
const [source, setSource] = useState<SourceAsset>(defaultSource);
const [recipe, setRecipe] = useState<AnimationRecipe>(presetRecipe);
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const [history, setHistory] = useState<string[]>([]);
const [status, setStatus] = useState("Preset preview is running.");
const [loading, setLoading] = useState(false);
const animationData = useMemo(
() => buildLottie(source, recipe),
[source, recipe]
);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setStatus(`Importing ${file.name}...`);
const imported = await parseAsset(file);
setSource(imported);
setStatus(`${file.name} is ready.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Import failed.");
} finally {
event.target.value = "";
}
}
async function generateMotion() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
setStatus("Write a motion request first.");
return;
}
setLoading(true);
setStatus("Claude is designing the motion...");
try {
const response = await fetch("/api/recipe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: trimmedPrompt,
currentRecipe: recipe
})
});
const payload = (await response.json()) as RecipeResponse;
if (!response.ok || !payload.recipe) {
throw new Error(payload.error ?? "Motion generation failed.");
}
setRecipe(payload.recipe);
setHistory((current) => [...current, trimmedPrompt]);
setStatus(`Applied ${payload.recipe.name}.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Motion generation failed.");
} finally {
setLoading(false);
}
}
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">Prompt-driven Lottie motion</p>
<h1>MotionPrompt Studio</h1>
<p>
Import one visual, describe the movement, refine it, and export a
self-contained Lottie JSON file.
</p>
</header>
<section className="studio-grid">
<aside className="panel controls-panel">
<div>
<span className="label">1. Source asset</span>
<label className="file-control">
<span>Choose SVG or Lottie JSON</span>
<input
type="file"
accept=".svg,.json,image/svg+xml,application/json"
onChange={handleFileChange}
/>
</label>
<p className="source-name">
{source.name} · {source.kind.toUpperCase()}
</p>
</div>
<div>
<label className="label" htmlFor="prompt">
2. Motion direction
</label>
<textarea
id="prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={5}
placeholder="Fade in, bounce once, then float upward"
/>
<button onClick={generateMotion} disabled={loading}>
{loading ? "Designing..." : "Generate Motion"}
</button>
</div>
<div>
<span className="label">3. Export</span>
<button
className="secondary-button"
onClick={() => downloadLottie(animationData, recipe.name)}
>
Download Lottie
</button>
</div>
<p className="status" aria-live="polite">
{status}
</p>
{history.length > 0 && (
<div>
<span className="label">Refinement history</span>
<ol className="history-list">
{history.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ol>
</div>
)}
</aside>
<section className="panel preview-panel">
<div className="preview-heading">
<div>
<span className="label">Live preview</span>
<h2>{recipe.name}</h2>
</div>
<span className="duration">{recipe.duration.toFixed(1)}s</span>
</div>
<div className="stage">
<AnimationPreview data={animationData} loop={recipe.loop} />
</div>
</section>
<section className="panel recipe-panel">
<div className="preview-heading">
<div>
<span className="label">Validated recipe</span>
<h2>{recipe.frames.length} keyframes</h2>
</div>
<span className="duration">{recipe.loop ? "Loop" : "Once"}</span>
</div>
<pre>{JSON.stringify(recipe.frames, null, 2)}</pre>
</section>
</section>
</main>
);
}
What should match?
Your file should contain the recipe response type plus the new request function. The button and history list should use the same state declared near the top of the component.
Page stopped compiling?
Confirm that RecipeResponse sits outside the component. Confirm that generateMotion() sits inside the component before the returned JSX.
Check that the request function closes before the return ( line. A misplaced brace can make the JSX appear inside the function.
Help me fix my React prompt request code.
Generate and refine the motion
The first request replaces the fixed preset with a model-generated recipe. The second request tests whether the server can refine the recipe already on screen.
API credits are used here
Each click in this test sends a metered Claude API request. These two requests deliberately prove both initial generation and follow-up refinement.
Prediction time: will the recipe name plus keyframes change after the first request? The preview gives you visible evidence.
- Enter Fade in, overshoot slightly, then drift upward in the motion direction field.
- Click Generate Motion.
The button should briefly show Designing.... The recipe name plus preview should change when the validated response arrives.
That is the hard-coded limitation removed. Your plain-language direction now controls bounded Lottie transform keyframes.
Prediction time: will the next request preserve the existing movement while removing rotation? The current recipe gives Claude the context to refine it.
- Replace the prompt with Make it slower and remove the rotation.
- Click Generate Motion again.
You should see the duration or timing change. Rotation values in the validated recipe should move to zero or stay close to zero.
Your refinement history should now list both prompts in submission order. The updated preview should use the second validated recipe.
- Use your browser menu to open its developer tools.
- Inspect the network activity created by the latest generation request.
- Select the request ending in /api/recipe.
- Inspect the request payload for the prompt plus current recipe.
- Search the loaded client source for ANTHROPIC_API_KEY.
The request payload should contain the prompt plus current recipe only. The client source should contain no API key value.
You now have prompt-generated motion that stays inside an application-safe recipe shape. Next, you will turn the current composition into a downloadable Lottie file and prove it survives re-import.
Export and Re-import the Lottie JSON
Your imported assets now respond to initial prompts and follow-up refinements. The studio can produce motion worth sharing.
A live preview stays inside the browser. This step serializes the current Lottie composition into portable JSON. You will then prove the downloaded file survives the studio's own import path.
In this step, get ready to:
- Serialize the current composition as a formatted Lottie JSON file.
- Connect a download control to the active animation and recipe name.
- Build the completed studio and prove the exported file can be re-imported.
Export the current composition
The current composition already contains the imported asset and its generated transform keyframes. The new downloadLottie() helper turns that complete document into a downloadable file.
- Switch back to src/motion.ts in Cursor.
- Add the following function below buildLottie().
export function downloadLottie(data: LottieDocument, recipeName: string) {
const safeName = recipeName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "prompt-motion";
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeName}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
What does this code do?
- The filename conversion replaces spaces and punctuation with hyphens. The fallback name keeps every download identifiable.
- JSON.stringify(data, null, 2) preserves the entire composition in an indented format.
- The Blob packages the serialized document as a browser-downloadable file.
- The temporary object URL connects the file to an invisible link. Revoking the URL releases it after the download starts.
- Save src/motion.ts.
- Check that the new helper compiles by running the project build:
npm run build
What does this build check?
The build runs the project's TypeScript check before Vite creates the production files. A successful result confirms that the export helper uses the existing Lottie document types correctly.
You should see the build complete without TypeScript errors. You should also see a new dist folder in the Explorer sidebar.
Build failing after the helper?
Check that downloadLottie() sits after the closing brace of buildLottie().
Compare the braces and quotes in the helper with the code above. One missing character prevents TypeScript from reading the function.
Still blocked? Help me diagnose the export helper build failure.
✔️ Awesome, I've got everything!
Great. Save src/motion.ts before wiring the helper into the studio.
ⓧ I'd like to double check the full code
import type {
AnimationRecipe,
LottieDocument,
MotionFrame,
SourceAsset
} from "./types";
const DEFAULT_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" rx="76" fill="#111827"/>
<circle cx="160" cy="160" r="98" fill="#8B5CF6"/>
<path d="M105 170 C125 105 195 105 215 170 C192 145 128 145 105 170Z" fill="#F8FAFC"/>
<circle cx="128" cy="176" r="12" fill="#111827"/>
<circle cx="192" cy="176" r="12" fill="#111827"/>
</svg>`.trim();
export const presetRecipe: AnimationRecipe = {
name: "Float In",
duration: 2.4,
loop: true,
frames: [
{ at: 0, x: 0, y: 34, scale: 0.8, rotation: -8, opacity: 0 },
{ at: 0.35, x: 0, y: 0, scale: 1.08, rotation: 2, opacity: 1 },
{ at: 0.58, x: 0, y: 0, scale: 0.98, rotation: 0, opacity: 1 },
{ at: 1, x: 0, y: -10, scale: 1, rotation: 0, opacity: 1 }
]
};
export const defaultSource: SourceAsset = {
kind: "svg",
name: "sample.svg",
width: 320,
height: 320,
dataUrl: `data:image/svg+xml;base64,${btoa(DEFAULT_SVG)}`
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const finiteNumber = (value: unknown): value is number =>
typeof value === "number" && Number.isFinite(value);
function isLottieDocument(value: unknown): value is LottieDocument {
return (
isRecord(value) &&
finiteNumber(value.fr) &&
finiteNumber(value.ip) &&
finiteNumber(value.op) &&
finiteNumber(value.w) &&
finiteNumber(value.h) &&
Array.isArray(value.layers) &&
(value.assets === undefined || Array.isArray(value.assets))
);
}
const readText = (file: File) => file.text();
const readDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
typeof reader.result === "string"
? resolve(reader.result)
: reject(new Error("The SVG could not be encoded."));
reader.onerror = () => reject(reader.error ?? new Error("File reading failed."));
reader.readAsDataURL(file);
});
function svgDimensions(source: string) {
const documentNode = new DOMParser().parseFromString(source, "image/svg+xml");
const svg = documentNode.documentElement;
if (svg.nodeName.toLowerCase() !== "svg") {
throw new Error("The selected file is not valid SVG.");
}
const viewBox = svg
.getAttribute("viewBox")
?.trim()
.split(/[ ,]+/)
.map(Number);
const width = Number.parseFloat(svg.getAttribute("width") ?? "") || viewBox?.[2] || 512;
const height = Number.parseFloat(svg.getAttribute("height") ?? "") || viewBox?.[3] || 512;
return { width, height };
}
export async function parseAsset(file: File): Promise<SourceAsset> {
const text = await readText(file);
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (isSvg) {
const { width, height } = svgDimensions(text);
return {
kind: "svg",
name: file.name,
width,
height,
dataUrl: await readDataUrl(file)
};
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("The selected JSON file could not be parsed.");
}
if (!isLottieDocument(parsed)) {
throw new Error("The JSON is missing required Lottie composition fields.");
}
return {
kind: "lottie",
name: file.name,
data: parsed
};
}
function uniqueId(base: string, ids: Set<string>) {
let candidate = base;
let suffix = 1;
while (ids.has(candidate)) {
candidate = `${base}_${suffix}`;
suffix += 1;
}
ids.add(candidate);
return candidate;
}
function propertyKeyframes(
frames: MotionFrame[],
totalFrames: number,
valueFor: (frame: MotionFrame) => number[]
) {
return {
a: 1,
k: frames.map((frame, index) => ({
t: Math.round(frame.at * (totalFrames - 1)),
s: valueFor(frame),
...(index < frames.length - 1
? {
i: { x: [0.667], y: [1] },
o: { x: [0.333], y: [0] }
}
: {})
}))
};
}
function staticTransform(width: number, height: number) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: { a: 0, k: [width / 2, height / 2, 0] },
s: { a: 0, k: [100, 100, 100] },
r: { a: 0, k: 0 },
o: { a: 0, k: 100 }
};
}
function motionTransform(
recipe: AnimationRecipe,
width: number,
height: number,
totalFrames: number
) {
return {
a: { a: 0, k: [width / 2, height / 2, 0] },
p: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
width / 2 + frame.x,
height / 2 + frame.y,
0
]),
s: propertyKeyframes(recipe.frames, totalFrames, (frame) => [
frame.scale * 100,
frame.scale * 100,
100
]),
r: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.rotation]),
o: propertyKeyframes(recipe.frames, totalFrames, (frame) => [frame.opacity * 100])
};
}
export function buildLottie(
source: SourceAsset,
recipe: AnimationRecipe
): LottieDocument {
const width = source.kind === "svg" ? source.width : source.data.w;
const height = source.kind === "svg" ? source.height : source.data.h;
const frameRate = source.kind === "svg" ? 30 : source.data.fr;
const totalFrames = Math.max(2, Math.round(recipe.duration * frameRate));
const assets: unknown[] =
source.kind === "lottie" ? structuredClone(source.data.assets ?? []) : [];
const ids = new Set<string>();
for (const asset of assets) {
if (isRecord(asset) && typeof asset.id === "string") {
ids.add(asset.id);
}
}
let sourceLayers: unknown[];
if (source.kind === "svg") {
const imageId = uniqueId("prompt_source_image", ids);
assets.push({
id: imageId,
w: width,
h: height,
p: source.dataUrl,
u: "",
e: 1
});
sourceLayers = [
{
ind: 1,
ty: 2,
nm: source.name,
refId: imageId,
ks: staticTransform(width, height),
ao: 0,
ip: 0,
op: totalFrames,
st: 0
}
];
} else {
sourceLayers = structuredClone(source.data.layers);
}
const compositionId = uniqueId("prompt_source_comp", ids);
assets.push({
id: compositionId,
layers: sourceLayers
});
return {
ver: 10001,
nm: recipe.name,
fr: frameRate,
ip: 0,
op: totalFrames,
w: Math.round(width),
h: Math.round(height),
assets,
layers: [
{
ind: 1,
ty: 0,
nm: "Prompt motion",
refId: compositionId,
w: Math.round(width),
h: Math.round(height),
sr: 1,
st: 0,
ks: motionTransform(recipe, width, height, totalFrames),
ao: 0,
ip: 0,
op: totalFrames
}
]
};
}
export function downloadLottie(data: LottieDocument, recipeName: string) {
const safeName = recipeName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "prompt-motion";
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeName}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
Wire the download control
The app already keeps the active composition in animationData. The export control passes that exact document to downloadLottie() with the validated recipe name.
- Switch back to src/App.tsx in Cursor.
- Replace the existing import from ./motion with this complete import group:
import {
buildLottie,
defaultSource,
downloadLottie,
parseAsset,
presetRecipe
} from "./motion";
Why update the import?
This makes the export helper available to the React component. The existing import and preview helpers stay unchanged.
- Find the motion direction section inside the component's returned interface.
- Insert this export section immediately after the motion direction section:
<div>
<span className="label">3. Export</span>
<button
className="secondary-button"
onClick={() => downloadLottie(animationData, recipe.name)}
>
Download Lottie
</button>
</div>
How does the control choose what to export?
The button exports animationData. That value contains the current source asset and the current validated recipe.
The recipe name becomes the download filename after the helper converts it to lowercase kebab case.
- Save src/App.tsx.
- Return to the existing app tab at http://localhost:5173.
- Refresh the page.
You should see a new section labelled 3. Export. It contains a Download Lottie button beneath the prompt composer.
Download control missing?
Confirm the export section sits inside the controls-panel aside. It should appear before the status paragraph.
Check that the import group includes downloadLottie with the same spelling as the function in src/motion.ts.
Need another pair of eyes? Help me find why the Download Lottie control is missing.
✔️ Awesome, I've got everything!
The export control is connected to the current composition. Save src/App.tsx before completing the round-trip test.
ⓧ I'd like to double check the full code
import { useMemo, useState, type ChangeEvent } from "react";
import { AnimationPreview } from "./AnimationPreview";
import {
buildLottie,
defaultSource,
downloadLottie,
parseAsset,
presetRecipe
} from "./motion";
import type { AnimationRecipe, SourceAsset } from "./types";
interface RecipeResponse {
recipe?: AnimationRecipe;
error?: string;
}
export default function App() {
const [source, setSource] = useState<SourceAsset>(defaultSource);
const [recipe, setRecipe] = useState<AnimationRecipe>(presetRecipe);
const [prompt, setPrompt] = useState("Fade in, overshoot slightly, then drift upward");
const [history, setHistory] = useState<string[]>([]);
const [status, setStatus] = useState("Preset preview is running.");
const [loading, setLoading] = useState(false);
const animationData = useMemo(
() => buildLottie(source, recipe),
[source, recipe]
);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
setStatus(`Importing ${file.name}...`);
const imported = await parseAsset(file);
setSource(imported);
setStatus(`${file.name} is ready.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Import failed.");
} finally {
event.target.value = "";
}
}
async function generateMotion() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
setStatus("Write a motion request first.");
return;
}
setLoading(true);
setStatus("Claude is designing the motion...");
try {
const response = await fetch("/api/recipe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: trimmedPrompt,
currentRecipe: recipe
})
});
const payload = (await response.json()) as RecipeResponse;
if (!response.ok || !payload.recipe) {
throw new Error(payload.error ?? "Motion generation failed.");
}
setRecipe(payload.recipe);
setHistory((current) => [...current, trimmedPrompt]);
setStatus(`Applied ${payload.recipe.name}.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Motion generation failed.");
} finally {
setLoading(false);
}
}
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">Prompt-driven Lottie motion</p>
<h1>MotionPrompt Studio</h1>
<p>
Import one visual, describe the movement, refine it, and export a
self-contained Lottie JSON file.
</p>
</header>
<section className="studio-grid">
<aside className="panel controls-panel">
<div>
<span className="label">1. Source asset</span>
<label className="file-control">
<span>Choose SVG or Lottie JSON</span>
<input
type="file"
accept=".svg,.json,image/svg+xml,application/json"
onChange={handleFileChange}
/>
</label>
<p className="source-name">
{source.name} · {source.kind.toUpperCase()}
</p>
</div>
<div>
<label className="label" htmlFor="prompt">
2. Motion direction
</label>
<textarea
id="prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={5}
placeholder="Fade in, bounce once, then float upward"
/>
<button onClick={generateMotion} disabled={loading}>
{loading ? "Designing..." : "Generate Motion"}
</button>
</div>
<div>
<span className="label">3. Export</span>
<button
className="secondary-button"
onClick={() => downloadLottie(animationData, recipe.name)}
>
Download Lottie
</button>
</div>
<p className="status" aria-live="polite">
{status}
</p>
{history.length > 0 && (
<div>
<span className="label">Refinement history</span>
<ol className="history-list">
{history.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ol>
</div>
)}
</aside>
<section className="panel preview-panel">
<div className="preview-heading">
<div>
<span className="label">Live preview</span>
<h2>{recipe.name}</h2>
</div>
<span className="duration">{recipe.duration.toFixed(1)}s</span>
</div>
<div className="stage">
<AnimationPreview data={animationData} loop={recipe.loop} />
</div>
</section>
<section className="panel recipe-panel">
<div className="preview-heading">
<div>
<span className="label">Validated recipe</span>
<h2>{recipe.frames.length} keyframes</h2>
</div>
<span className="duration">{recipe.loop ? "Loop" : "Once"}</span>
</div>
<pre>{JSON.stringify(recipe.frames, null, 2)}</pre>
</section>
</section>
</main>
);
}
Build and prove the round trip
A portable export must survive outside the preview that created it. Re-importing the downloaded document makes the importer validate its root fields before wrapping its layers in a new precomposition.
Before you run the final build, do you expect TypeScript and Vite to accept both export changes?
- Verify the completed studio by running the production build:
npm run build
What does the final build prove?
The command checks the complete TypeScript project before bundling the browser app. It proves the new import and button handler connect to a valid export function.
Vite writes the production result to dist. That folder is the compiled version of the finished studio.
You should see the build finish successfully. The dist folder should contain the generated production files.
Final build not completing?
Check that downloadLottie appears in the import group and the button handler with identical capitalization.
Confirm that both edited files are saved. An unsaved helper leaves the app import without a matching export.
Still seeing a build problem? Help me debug the final MotionPrompt Studio build.
Before you re-import the download, do you expect the file produced by the studio to pass the same validation used for external Lottie files?
- Return to the running MotionPrompt Studio browser tab.
- Note the active recipe name shown above the live preview.
- Click Download Lottie in the export section.
- Confirm a .json file named from the active recipe appears in your Downloads folder.
- Click Choose SVG or Lottie JSON in the source asset section.
- Select the downloaded .json file from your Downloads folder.
You should see the source label change to the downloaded filename with LOTTIE. The re-imported animation should continue playing with the current motion on the stage.
What did the round trip prove?
The exported file contains its referenced assets and layers inside one Lottie document. The importer can validate that document without relying on the original SVG file.
The imported layers become the source of a new outer motion composition. That adapter pattern keeps the importer independent from arbitrary internal layer structures.
Downloaded file not re-importing?
Select the file created by Download Lottie. The importer accepts the generated .json file directly.
Confirm the downloaded file has a non-empty name ending in .json. A renamed file with another extension does not match the file picker filter.
Need help tracing the round trip? Help me debug my exported Lottie JSON re-import.
That is the portability proof complete. Your studio now turns the current preview into a self-contained file that can return through its own importer.
Secret mission
Run an Interoperability Stress Test
Round-trip three distinct recipes using the included SVG. Test one third-party Lottie file. Document each pass or limitation to map the compatibility boundary.
Clean Up Your Resources
Clean Up Your Resources
Your local Express and Vite processes do not create ongoing infrastructure charges. Claude API usage remains metered whenever you generate a new motion recipe.
Decide whether to keep your project for more experiments, pause it for later, or remove every local artifact and credential.
Resources you used:
- Your local motion-prompt-studio folder, including the dist build and untracked .env credential file.
- Your downloaded Lottie JSON files for the subtle entrance, looping float, fast spin-and-scale motion, and optional third-party round-trip test.
- Your interoperability report with pass results, limitations, and compatibility-boundary notes.
- Your personal Claude Console API key.
Keep everything running
No deletion is needed. Choose this if you plan to refine MotionPrompt Studio or run more interoperability tests soon.
- Stop the Express API server by pressing Control+C in its terminal panel.
- Stop the Vite development server by pressing Control+C in its terminal panel.
- Keep the motion-prompt-studio folder with its source files, dist build, and local .env file.
- Keep the exported Lottie JSON files for future playback and compatibility testing.
- Keep the interoperability report as a record of the boundaries you tested.
- Leave the Claude Console API key active for future recipe requests.
Your files remain available after both local processes stop. New Claude API requests can continue to consume credits when you restart the studio.
Pause - I'll come back to this later
Pause the running processes while preserving the project, exports, report, and credential. This frees your terminal without dismantling the studio.
- Stop the Express API server by pressing Control+C in its terminal panel.
- Stop the Vite development server by pressing Control+C in its terminal panel.
- Keep the motion-prompt-studio folder in its current location.
- Keep the downloaded Lottie JSON files with your interoperability report.
- Leave the Claude Console API key active for your next session.
The purchased or included API credits remain available while the credential stays active.
Delete - I don't want to use this again
Remove the project artifacts and revoke access through the personal API key. Deleting the key is permanent, while disabling it remains the reversible option.
- Stop the Express API server by pressing Control+C in its terminal panel.
- Stop the Vite development server by pressing Control+C in its terminal panel.
Both local development processes are now stopped. The remaining actions remove the files and credential.
- Move the motion-prompt-studio folder to your Mac's Trash.
- Move the downloaded Lottie JSON files from the subtle entrance, looping float, fast spin-and-scale motion, and optional third-party round-trip test to your Mac's Trash.
- Move the interoperability report to your Mac's Trash.
- Empty your Mac's Trash to remove the local artifacts permanently.
- Return to the Claude Console API keys page.
- Locate the personal API key you created for MotionPrompt Studio.
- Click Delete to remove the credential permanently.
Nice Work!
Nice Work!
You did it! MotionPrompt Studio now turns plain-language prompts into validated Lottie animation. Its downloaded JSON files survive re-import with the same motion.
What you learned:
- Built a React motion studio with TypeScript that imports SVG or Lottie JSON assets into a live grouped preview.
- Protected the Claude API credential behind an Express endpoint. Used structured outputs with Zod to create application-safe motion recipes.
- Converted validated recipes into position, scale, rotation, and opacity keyframes. Exported self-contained Lottie JSON files that preserved their motion after re-import.
- Secret Mission: Stress-tested three SVG-based motion styles through export plus re-import. Documented where a third-party Lottie round trip passed or reached the studio's compatibility boundary.
Ready to quiz yourself?