diff --git a/corpus/frontend/motion/animatepresence.yaml b/corpus/frontend/motion/animatepresence.yaml
new file mode 100644
index 0000000..fe02a36
--- /dev/null
+++ b/corpus/frontend/motion/animatepresence.yaml
@@ -0,0 +1,90 @@
+name: AnimatePresence
+kind: component
+description: >-
+ Enables exit animations when children are removed from the React tree. Direct
+ children must have unique key props.
+importPath: 'import { AnimatePresence } from "motion/react"'
+props:
+ - name: initial
+ type: boolean
+ description: Set false to disable initial animations on first render.
+ default: "true"
+ - name: mode
+ type: "'sync' | 'wait' | 'popLayout'"
+ description: "sync: simultaneous enter/exit. wait: exit completes before enter. popLayout: pop exiting elements out of layout flow."
+ default: "'sync'"
+ - name: custom
+ type: any
+ description: Data passed to exiting components via usePresenceData().
+ - name: onExitComplete
+ type: () => void
+ description: Fires when all exit animations finish.
+ - name: propagate
+ type: boolean
+ description: If true, nested AnimatePresence children fire exit animations when parent exits.
+ - name: root
+ type: ShadowRoot | HTMLElement
+ description: Root element for popLayout styles. Defaults to document.head. Set to a ShadowRoot for shadow DOM usage.
+usage: |
+
+ {show && (
+
+ )}
+
+examples:
+ - title: Modal with exit animation
+ category: exit
+ code: |
+ function Modal({ isOpen, onClose }) {
+ return (
+
+ {isOpen && (
+
+ e.stopPropagation()}
+ >
+ Modal content
+
+
+ )}
+
+ );
+ }
+ - title: Page transitions with wait mode
+ category: exit
+ code: |
+
+
+ {children}
+
+
+tips:
+ - "AnimatePresence must wrap the conditional - it goes OUTSIDE the {show && ...}."
+ - Each direct child needs a unique key prop.
+ - mode='wait' is useful for page transitions where old page exits before new enters.
+ - "mode='popLayout' pops exiting elements out of document flow immediately. Custom component children must use forwardRef (React 18) or accept ref prop (React 19)."
+relatedApis:
+ - motion
+ - usePresence
+ - useIsPresent
+ - usePresenceData
diff --git a/corpus/frontend/motion/index.yaml b/corpus/frontend/motion/index.yaml
index ff0d4b8..0bc019a 100644
--- a/corpus/frontend/motion/index.yaml
+++ b/corpus/frontend/motion/index.yaml
@@ -2,6 +2,12 @@ namespace: frontend.motion
apis:
motion:
file: motion.yaml
+ animatepresence:
+ file: animatepresence.yaml
+ usescroll:
+ file: usescroll.yaml
+ usespring:
+ file: usespring.yaml
examples:
scroll:
file: examples/scroll.yaml
diff --git a/corpus/frontend/motion/usescroll.yaml b/corpus/frontend/motion/usescroll.yaml
new file mode 100644
index 0000000..a90b612
--- /dev/null
+++ b/corpus/frontend/motion/usescroll.yaml
@@ -0,0 +1,106 @@
+name: useScroll
+kind: hook
+description: >-
+ Creates scroll-linked motion values. Tracks window or element scroll
+ position. Supports hardware-accelerated ScrollTimeline.
+importPath: 'import { useScroll } from "motion/react"'
+returns: "{ scrollX, scrollY, scrollXProgress, scrollYProgress }"
+props:
+ - name: container
+ type: RefObject
+ description: "Scrollable element ref. Default: window."
+ - name: target
+ type: RefObject
+ description: Element to track progress through container.
+ - name: axis
+ type: "'x' | 'y'"
+ description: Scroll axis.
+ default: "'y'"
+ - name: offset
+ type: "[string, string]"
+ description: "Start/end intersection points. Format: 'targetPoint containerPoint'. Points: start, center, end, 0-1, px, %, vh, vw."
+ default: '["start start", "end end"]'
+ - name: trackContentSize
+ type: boolean
+ description: Track content size changes.
+ default: "false"
+usage: |
+ // Window scroll
+ const { scrollYProgress } = useScroll();
+
+ // Element scroll
+ const ref = useRef(null);
+ const { scrollYProgress } = useScroll({ container: ref });
+
+ // Track element through viewport
+ const ref = useRef(null);
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ["start end", "end start"]
+ });
+examples:
+ - title: Scroll progress bar
+ category: scroll
+ code: |
+ function ProgressBar() {
+ const { scrollYProgress } = useScroll();
+
+ return (
+
+ );
+ }
+ - title: Element reveal on scroll
+ category: scroll
+ code: |
+ function RevealSection() {
+ const ref = useRef(null);
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ["start end", "center center"]
+ });
+ const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
+ const y = useTransform(scrollYProgress, [0, 1], [100, 0]);
+
+ return (
+
+ Content revealed on scroll
+
+ );
+ }
+ - title: Horizontal scroll section
+ category: scroll
+ code: |
+ function HorizontalScroll() {
+ const containerRef = useRef(null);
+ const { scrollYProgress } = useScroll({ target: containerRef });
+ const x = useTransform(scrollYProgress, [0, 1], ["0%", "-75%"]);
+
+ return (
+
+
+
+ {panels.map(panel => )}
+
+
+
+ );
+ }
+tips:
+ - "Offset format: ['start end', 'end start'] means animation starts when target's start hits viewport end, ends when target's end hits viewport start."
+ - Use useTransform to map scrollYProgress to transform/opacity/filter for GPU-accelerated animations.
+ - Combine with useSpring for smoothed scroll animations.
+relatedApis:
+ - useTransform
+ - useSpring
+ - useMotionValue
diff --git a/corpus/frontend/motion/usespring.yaml b/corpus/frontend/motion/usespring.yaml
new file mode 100644
index 0000000..eb8e2ad
--- /dev/null
+++ b/corpus/frontend/motion/usespring.yaml
@@ -0,0 +1,79 @@
+name: useSpring
+kind: hook
+description: >-
+ Creates a motion value that animates to targets with spring physics. Can
+ track another motion value.
+importPath: 'import { useSpring } from "motion/react"'
+returns: MotionValue
+props:
+ - name: stiffness
+ type: number
+ description: Spring stiffness.
+ default: "1"
+ - name: damping
+ type: number
+ description: Spring damping.
+ default: "10"
+ - name: mass
+ type: number
+ description: Mass of the moving object.
+ default: "1"
+ - name: bounce
+ type: number
+ description: Bounciness (0-1).
+ default: "0.25"
+ - name: duration
+ type: number
+ description: Duration in seconds (spring will be configured to match).
+ - name: visualDuration
+ type: number
+ description: Perceived duration (spring settles to 1/10 of movement).
+ - name: skipInitialAnimation
+ type: boolean
+ description: Skip animation on initial render.
+ default: "false"
+usage: |
+ // Direct control
+ const x = useSpring(0);
+ x.set(100); // springs to 100
+
+ // Track another value
+ const scrollY = useMotionValue(0);
+ const smoothY = useSpring(scrollY, { stiffness: 100, damping: 30 });
+examples:
+ - title: Smooth scroll tracking
+ category: scroll
+ code: |
+ function SmoothScroll() {
+ const { scrollYProgress } = useScroll();
+ const smoothProgress = useSpring(scrollYProgress, {
+ stiffness: 100,
+ damping: 30,
+ restDelta: 0.001
+ });
+
+ return ;
+ }
+ - title: Mouse follower
+ category: animation
+ code: |
+ function Cursor() {
+ const x = useMotionValue(0);
+ const y = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 300, damping: 25 });
+ const springY = useSpring(y, { stiffness: 300, damping: 25 });
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ x.set(e.clientX);
+ y.set(e.clientY);
+ };
+ window.addEventListener("mousemove", handler);
+ return () => window.removeEventListener("mousemove", handler);
+ }, []);
+
+ return ;
+ }
+relatedApis:
+ - useMotionValue
+ - useTransform
diff --git a/src/plugins/motion/tools/get-api.ts b/src/plugins/motion/tools/get-api.ts
index 82f0d7f..3999369 100644
--- a/src/plugins/motion/tools/get-api.ts
+++ b/src/plugins/motion/tools/get-api.ts
@@ -16,11 +16,7 @@ type CorpusIndex = {
type CorpusNamespaceIndex = {
namespace?: string;
- apis?: {
- motion?: {
- file?: string;
- };
- };
+ apis?: Record;
};
type CorpusApiEntry = {
@@ -34,18 +30,24 @@ type CorpusApiEntry = {
examples?: Array<{ title: string; code: string; description?: string; category: string }>;
tips?: string[];
relatedApis?: string[];
- source?: string;
};
+type LoadedCorpusApi = { api: CorpusApiEntry; source: string };
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(moduleDir, "../../../../corpus");
const corpusNamespace = "frontend.motion";
-let cachedCorpusMotionApi: { api: CorpusApiEntry; source: string } | null | undefined;
+const cachedCorpusApis = new Map();
-function loadCorpusMotionApi(): { api: CorpusApiEntry; source: string } | null {
- if (cachedCorpusMotionApi !== undefined) {
- return cachedCorpusMotionApi;
+function normalizeApiName(name: string): string {
+ return name.toLowerCase().replace(/[<>/()]/g, "").trim();
+}
+
+function loadCorpusApi(name: string): LoadedCorpusApi | null {
+ const key = normalizeApiName(name);
+ if (cachedCorpusApis.has(key)) {
+ return cachedCorpusApis.get(key) ?? null;
}
try {
@@ -53,46 +55,41 @@ function loadCorpusMotionApi(): { api: CorpusApiEntry; source: string } | null {
const index = YAML.parse(indexRaw) as CorpusIndex | null;
const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index;
if (!namespaceIndexPath) {
- cachedCorpusMotionApi = null;
- return cachedCorpusMotionApi;
+ cachedCorpusApis.set(key, null);
+ return null;
}
const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8");
const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null;
- const apiPath = namespaceIndex?.apis?.motion?.file;
+ const apiPath = namespaceIndex?.apis?.[key]?.file;
if (!apiPath) {
- cachedCorpusMotionApi = null;
- return cachedCorpusMotionApi;
+ cachedCorpusApis.set(key, null);
+ return null;
}
const apiRaw = readFileSync(join(corpusRoot, "frontend/motion", apiPath), "utf8");
const api = YAML.parse(apiRaw) as CorpusApiEntry | null;
- if (!api || api.name?.toLowerCase() !== "motion" || !api.usage || !api.importPath || !api.description || !api.kind) {
- cachedCorpusMotionApi = null;
- return cachedCorpusMotionApi;
+ if (
+ !api ||
+ normalizeApiName(api.name ?? "") !== key ||
+ !api.usage ||
+ !api.importPath ||
+ !api.description ||
+ !api.kind
+ ) {
+ cachedCorpusApis.set(key, null);
+ return null;
}
- cachedCorpusMotionApi = { api, source: corpusNamespace };
- return cachedCorpusMotionApi;
+ const loaded: LoadedCorpusApi = { api, source: corpusNamespace };
+ cachedCorpusApis.set(key, loaded);
+ return loaded;
} catch {
- cachedCorpusMotionApi = null;
- return cachedCorpusMotionApi;
+ cachedCorpusApis.set(key, null);
+ return null;
}
}
-function getMotionApi(name: string) {
- if (name.toLowerCase() !== "motion") {
- return getApiByName(name);
- }
-
- const corpusEntry = loadCorpusMotionApi();
- if (corpusEntry) {
- return corpusEntry.api as Parameters[0];
- }
-
- return getApiByName(name);
-}
-
export function register(server: McpServer): void {
server.tool(
"motion_get_api",
@@ -101,8 +98,8 @@ export function register(server: McpServer): void {
name: z.string().describe("API name (e.g., 'motion', 'AnimatePresence', 'useAnimate', 'useScroll', 'stagger', 'Reorder.Group')"),
},
async ({ name }) => {
- const corpusEntry = name.toLowerCase() === "motion" ? loadCorpusMotionApi() : null;
- const api = getMotionApi(name);
+ const corpusEntry = loadCorpusApi(name);
+ const api = corpusEntry?.api ?? getApiByName(name);
if (!api) {
const suggestions = searchApis(name).map((r) => r.api.name);
return {
@@ -110,7 +107,7 @@ export function register(server: McpServer): void {
isError: true,
};
}
- const rendered = formatApiReference(api);
+ const rendered = formatApiReference(api as Parameters[0]);
return {
content: [
{
diff --git a/tests/motion-corpus-backed-tools-behaviour.test.ts b/tests/motion-corpus-backed-tools-behaviour.test.ts
index a48baec..fb9ab04 100644
--- a/tests/motion-corpus-backed-tools-behaviour.test.ts
+++ b/tests/motion-corpus-backed-tools-behaviour.test.ts
@@ -13,11 +13,41 @@ test("motion_get_api prefers corpus metadata for motion", async () => {
expect(text).toContain('import { motion } from "motion/react"');
});
-test("motion_get_api falls back to in-file data for non-corpus motion APIs", async () => {
+test("motion_get_api prefers corpus metadata for AnimatePresence", async () => {
const result = await motionGetApi.invoke({ name: "AnimatePresence" });
const text = extractTextContent(result);
expect(text).toContain("# AnimatePresence");
expect(text).toContain("**Kind:** component");
+ expect(text).toContain("**Corpus Source:** frontend.motion");
+ expect(text).toContain('import { AnimatePresence } from "motion/react"');
+});
+
+test("motion_get_api prefers corpus metadata for useScroll", async () => {
+ const result = await motionGetApi.invoke({ name: "useScroll" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# useScroll");
+ expect(text).toContain("**Kind:** hook");
+ expect(text).toContain("**Corpus Source:** frontend.motion");
+ expect(text).toContain('import { useScroll } from "motion/react"');
+});
+
+test("motion_get_api prefers corpus metadata for useSpring", async () => {
+ const result = await motionGetApi.invoke({ name: "useSpring" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# useSpring");
+ expect(text).toContain("**Kind:** hook");
+ expect(text).toContain("**Corpus Source:** frontend.motion");
+ expect(text).toContain('import { useSpring } from "motion/react"');
+});
+
+test("motion_get_api falls back to in-file data for non-corpus motion APIs", async () => {
+ const result = await motionGetApi.invoke({ name: "useAnimate" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# useAnimate");
+ expect(text).toContain("**Kind:** hook");
expect(text).not.toContain("**Corpus Source:** frontend.motion");
});