Webflow Mcp:Interactions logo

Webflow Mcp:Interactions

Organization
webflow
webflow-mcp:interactions

Create, update, list, and delete Webflow IX3 interactions (GSAP animations) through Webflow MCP. Use when the user wants click/hover/load/scroll/mouse-move animations, interaction timelines, or data_interactions_tool / create_interaction payloads. Requires beta MCP + ff-ix3-interaction-apis during dogfood.

Overview

Publisherwebflow
Repositorywebflow-skills
Skill namewebflow-mcp:interactions
Stars
122
Forks
18
Bundled files
17
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 17 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by webflow on GitHub. Read the source before you install it.

Installation

Install the Webflow Mcp:Interactions AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/webflow/webflow-skills.git /tmp/webflow-skills
mkdir -p .claude/skills
cp -r /tmp/webflow-skills/plugins/webflow-skills/skills/interactions .claude/skills/webflow-webflow-mcp-interactions
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Webflow Mcp:Interactions in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Webflow Mcp:Interactions on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Webflow Mcp:Interactions is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Interactions

Create and edit IX3 interactions (GSAP animations) through Webflow MCP.

Important Note

ALWAYS use Webflow MCP tools for all operations:

  • Use Webflow MCP's webflow_guide_tool to get best practices before any other tool call — it covers general MCP conventions and returns nothing about interactions. IX3 payload shapes come from data_interactions_tool action guide
  • Use Webflow MCP's data_sites_tool with action list_sites to identify the target site
  • Use Webflow MCP's data_pages_tool with action list_pages to find the target page by name or slug
  • Use Webflow MCP's data_interactions_tool for guide / list / get / create / update / delete
  • Use Webflow MCP's data_style_tool to resolve class style-block ids before targeting wf:class
  • DO NOT use any other tools or methods for Webflow interaction CRUD
  • All tool calls must include the required context parameter (15-25 words, third-person perspective)
  • No Designer or MCP Bridge required. data_interactions_tool is headless, the same way data_element_tool is. Designer is only useful afterward, to inspect the Interactions panel or Preview. Do not ask the user to open the Bridge app in order to list or write interactions.
  • After Webflow monorepo PR #117284 ships: if the Webflow Filesystem (WFS) interactions lane is built, prefer site/interactions/interactions.ix3.jsondata_interactions_tool is then unregistered. Until that PR lands, the tool is still registered even in WFS sessions. This skill is the MCP CRUD path.

Tool surface (beta dogfood)

These tools are not on stable MCP.

  • Server: https://mcp.webflow.com/beta/mcp
  • Flag: ff-ix3-interaction-apis
  • Scopes: pages:read / pages:write
  • Compound tool: data_interactions_tool
  • Actions: guide, list_interactions, get_interaction, create_interaction, update_interaction, delete_interaction
  • siteId and pageId are top-level tool arguments (page context / create bookkeeping). They are not inside create_interaction args.
  • guide is live. Call data_interactions_tool with action guide, or read webflow://guides/interactions. Same markdown either way. Prefer it for legal payload shapes. This skill is the workflow around those shapes; references/ are the set-pieces the guide does not carry.
  • create_interaction args: name (required), scope (optional, default site), triggers (required array), timelines (required array), optional timelineDefaults, optional conditionalPlayback
  • Component and variant scope work. {type:"component", componentId, variants?} is accepted on create and update. variants holds variant option ids from data_component_variants_tool — omitting it or passing [] both mean every variant. Do not set libraryProfileId; it marks the interaction library-owned. A component or variant id that does not exist is rejected. See references/envelope-and-targets.md.

Instructions

Phase 1: Discovery

  1. Call webflow_guide_tool first — always the first MCP tool call. Be clear about what it gives you: general MCP tool conventions, and nothing about interactions. Its response contains no occurrence of interaction, ix3, wf:click, or scrollTrigger today. Call it for the site/page/element conventions, then get IX3 payload shapes from data_interactions_toolguide — do not read webflow_guide_tool's silence as "there is nothing to know."
  2. Get the site: data_sites_tool with list_sites. If only one site exists, use it.
  3. Get the page: data_pages_tool with list_pages. You need that page's ID as top-level pageId on every data_interactions_tool call.
  4. Confirm the gate: beta MCP endpoint and ff-ix3-interaction-apis covering the caller's identity. If data_interactions_tool is unregistered, stop and tell the user. The likely causes are the stable MCP endpoint instead of beta, or the flag not covering that identity. Do not treat a missing tool as a missing Bridge session, and do not ask the user to open Designer or the MCP Bridge. Do not work around it. ff-ix3-interaction-de-api is a different flag, for the Designer Extension iframe surface; it is not what this tool needs.

Phase 2: Read the contract for what you are building

  1. Call data_interactions_toolguide for legal payload shapes. Then read the references/ file for your trigger when you need a set-piece the guide does not carry. See the Reference map below. Do this before your first write on anything beyond the five inline examples in this file.
  2. Do not invent += / < / > timing.position strings or {reducedMotion:"skip"}. A bare number for timing.duration is seconds (0.4, not 400).
Reference map
User asks forRead
Clickreferences/trigger-click.md
Hover, mouse enter/leavereferences/trigger-hover.md
Page loadreferences/trigger-load.md
Scroll, scrub, parallaxreferences/trigger-scroll.md
Mouse move, cursor followreferences/trigger-mouse-move.md
Custom JS eventreferences/trigger-custom.md
Navbar, dropdown, conditions, Rive, variablesreferences/gated-capabilities.md
Envelope, IDs, scope, targets, filtersreferences/envelope-and-targets.md
Properties, values, tt, timing, splitTextreferences/actions-and-properties.md
Roles, groups, percent canvasreferences/timelines-and-groups.md
Reduced motion, breakpoint playbackreferences/conditional-playback.md
Editing an existing interactionreferences/updating-interactions.md
Size and count limitsreferences/limits-and-budgets.md
A write succeeded but the user cannot edit itreferences/panel-traps.md
A write succeeded but nothing animatesreferences/rejects-index.md → "nothing to decode"
Decoding a rejection messagereferences/rejects-index.md
Which triggers and properties exist at allreferences/capabilities.generated.md

Start at references/index.md if you are unsure. It also explains the enforcement tags — in particular [PANEL-TRAP], which means the write will succeed but leave the user with something they cannot edit in the Designer, and [LEGACY-OK-ON-UPDATE], which means you must pass a stored value through untouched rather than "fixing" it.

Phase 3: Plan (before any write)

  1. Resolve class targets: prefer style-block id arrays from data_style_tool; class name strings are accepted. Confirm with query_styles that the class resolves to exactly one style block — a name reused as the leaf of several combo chains returns several, and each targets a different element. When an element needs its own class, create it with create_style naming the full parent_style_names chain you intend to apply, then apply that same chain with data_element_tool set_style; a chain that does not already exist as a style block is refused
  2. Plan the payload against the reference you read in Phase 2: object format, legal trigger/target, fresh action ids
  3. Request explicit confirmation before create/update/delete:
    • "Would you like me to create this click fade?"
    • "Before I write this interaction: [plan]. Confirm to proceed."

Phase 4: Write (after confirmation only)

  1. create_interaction or update_interaction on data_interactions_tool
  2. update_interaction is a partial: omitted fields stay, provided values replace, null clears timelineDefaults / conditionalPlayback. timelines is a full replace when sent.

Phase 5: Verify

  1. get_interaction with the returned id
  2. Report what was created/updated
  3. On reject: read the error, look it up in references/rejects-index.md, fix the payload. Do not invent GSAP position operators, and do not retry the same shape hoping for a different result — every rejection here is deterministic.
  4. A successful write is not proof the animation runs. Several legal payloads save, read back byte-identical, and do nothing: a [from, to] pair on a To tween, a class array that is not one combo chain, a mouse-move trigger with no target, control: "reverse" on a first click. get_interaction cannot detect any of them. Check the payload against the Guidelines below before reporting success, and if the user says nothing happens, start at references/rejects-index.md → "When there is nothing to decode" instead of rewriting the interaction.

Examples

Replace STYLE_BLOCK_ID with a style-block id. Mint a fresh unique id on every action.

Example 1: Click fade

User: "Add a click fade interaction"

  1. Call webflow_guide_tool
  2. list_siteslist_pages → resolve STYLE_BLOCK_ID via data_style_tool
  3. Call data_interactions_toolguide for payload shapes. Read references/trigger-click.md only if you need a set-piece the guide does not carry.
  4. Present the plan and wait for confirmation
  5. After confirmation, create_interaction:
json
{
  "name": "Click fade",
  "triggers": [
    {
      "extensionKey": "wf:click",
      "config": { "control": "play" },
      "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
    }
  ],
  "timelines": [
    {
      "actions": [
        {
          "id": "act-click-fade",
          "name": "Fade",
          "tt": 2,
          "timing": { "duration": 0.4 },
          "properties": { "wf:transform": { "opacity": ["0%", "100%"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}
  1. get_interaction to verify

A FromTo holds its from-state at rest, so this element renders at opacity: 0 until the first click — measured on a published page. That is what fading in means, but tell the user, because an element they cannot see reads as a failed write rather than a working interaction. If it should be visible before the click, animate a property whose rest value is already visible (an x offset, a colour) or use a To.

Example 2: Page load fade

User: "Fade this section in when the page loads"

Same discovery, reference read, and confirmation. Load omits the trigger target. Action targets must not be wf:trigger-only.

json
{
  "name": "Load fade",
  "triggers": [{ "extensionKey": "wf:load", "config": { "control": "play" } }],
  "timelines": [
    {
      "actions": [
        {
          "id": "act-load-fade",
          "name": "Fade",
          "tt": 2,
          "timing": { "duration": 0.4 },
          "properties": { "wf:transform": { "opacity": ["0%", "100%"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}

Example 3: Scroll scrub

User: "Scrub a fade as the user scrolls"

Scroll is standalone. A scrub needs a numeric scrub (not true), a roleless timeline with canvasDuration: 1, and action timing.duration equal to that canvas so the tween spans the full pass. Omit playback control / delay / jump / speed. Omitting scrub is a one-shot play when the range is crossed — not a scrub.

json
{
  "name": "Scroll scrub fade",
  "triggers": [
    {
      "extensionKey": "wf:scroll",
      "config": {
        "scrollTriggerConfig": {
          "start": "top bottom",
          "end": "top 10%",
          "scrub": 0.3
        }
      },
      "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
    }
  ],
  "timelines": [
    {
      "canvasDuration": 1,
      "actions": [
        {
          "id": "act-scroll-scrub",
          "name": "Fade",
          "tt": 2,
          "timing": { "duration": 1 },
          "properties": {
            "wf:transform": { "opacity": ["0%", "100%"], "xPercent": [-40, 0] }
          },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}

Play-once variant (a reveal, not a scrub). Drop scrub and canvasDuration, put timing.duration back in seconds — and send enter: "play". The host stamps absent toggle keys on create and on trigger replacement (enter: "play", the other three "none") and leaves an explicit "none" alone, so an omission is repaired rather than left inert. Send enter anyway: it is what the panel writes, and it is correct on both sides of that rollout.

json
{
  "extensionKey": "wf:scroll",
  "config": {
    "scrollTriggerConfig": {
      "start": "top 90%",
      "end": "bottom 15%",
      "enter": "play",
      "leaveBack": "reset"
    }
  },
  "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
}

leaveBack: "reset" is optional and replays the reveal when the user scrolls back up. Use a From (tt: 1) for the reveal itself so the element starts hidden.

Example 4: Hover enter / leave

User: "Fade in on hover enter and out on leave"

Use the role form: one wf:hover trigger with multiTimeline: true, and two timelines tagged mouseEnter and mouseLeave via triggerMetadata. Roles must be exactly those strings and unique per timeline. Do not mix this with legacy hover type / hover / custom.

json
{
  "name": "Hover fade",
  "triggers": [
    {
      "extensionKey": "wf:hover",
      "config": { "pluginConfig": { "multiTimeline": true } },
      "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
    }
  ],
  "timelines": [
    {
      "triggerMetadata": { "role": "mouseEnter" },
      "actions": [
        {
          "id": "act-hover-in",
          "name": "Fade in",
          "tt": 2,
          "timing": { "duration": 0.3 },
          "properties": { "wf:transform": { "opacity": ["0%", "100%"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    },
    {
      "triggerMetadata": { "role": "mouseLeave" },
      "actions": [
        {
          "id": "act-hover-out",
          "name": "Fade out",
          "tt": 2,
          "timing": { "duration": 0.3 },
          "properties": { "wf:transform": { "opacity": ["100%", "0%"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}

No caveat needed any more. An earlier version of this skill warned that the panel would not offer a remove button for either action group. That was true when written, and webflow#117817 fixed it on 2026-08-19 — Remove now renders for this shape when a Hover owner exists. Confirmed in the Designer afterwards: a role-form hover authored through MCP animates correctly and shows a delete control on both Actions groups, the same as the split form.

The two-trigger split the panel prefers is authorable, but the discriminator is config.pluginConfig.eventMode, with the values 'enter' / 'leave' — not mouseEnter/mouseLeave, and not at config level, where it is silently discarded (see the first Guidelines section). eventMode also requires a boolean multiTimeline beside it, and the interaction needs control: "play" once it has two or more action groups.

Use multiTimeline: false on the split, not true. The runtime branches on that flag: true is two-group role mode, which emits mouseEnter / mouseLeave role callbacks and is the role form above; false is single-group mode, where each trigger drives its own group and eventMode gates which event binds (bindEnter = eventMode !== 'leave'). The split routes by groupId, so it wants false.

json
"triggers": [
  {
    "extensionKey": "wf:hover",
    "config": {
      "control": "play",
      "assignedGroupId": "grp-in",
      "pluginConfig": { "multiTimeline": false, "eventMode": "enter" }
    },
    "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
  },
  {
    "extensionKey": "wf:hover",
    "config": {
      "control": "play",
      "assignedGroupId": "grp-out",
      "pluginConfig": { "multiTimeline": false, "eventMode": "leave" }
    },
    "target": { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
  }
]

with groupId: "grp-in" / "grp-out" on the two timelines. groupId survives to the host, and an assignedGroupId matching no timeline group is rejected rather than stored inert. Note multi-timeline hover routes by timeline role first, then by group.

Both forms work, and neither costs the user anything measurable. Verified side by side in the Designer, both authored through MCP against the same element: identical playback (y 0 → -24 on enter, -24 → 0 on leave) and a delete control on both action groups in both forms.

So this is a weak preference: prefer the split form above, because it is the shape the panel writes itself and the user sees what they would have built by hand. The role form (Example 4) is equally valid — the panel does not write it, but it does edit it.

Earlier versions of this skill told agents to trade playback fidelity against a missing remove button. There is no such tradeoff. See references/trigger-hover.md.

For enter only, send one trigger with pluginConfig: { "multiTimeline": false } and a single timeline.

If the user only wants an enter animation, send one trigger with pluginConfig: { "multiTimeline": false } — omitting the key drops to the legacy editor instead. Either way, the panel may not offer "Add separate hover out" on a single-timeline hover, so author the full split above whenever the user wants both directions.

Example 5: Mouse-move

User: "Move this element with the cursor"

Mouse-move is standalone. It validates without a target but never fires without one, so always send a targetwf:viewport (value: "") for page-wide tracking, or wf:inst / wf:class to scope it. Every timeline needs a unique role from mouseX / mouseY / interval. Omit playback control / delay / jump / speed. See references/trigger-mouse-move.md.

json
{
  "name": "Mouse follow",
  "triggers": [
    {
      "extensionKey": "wf:mouse-move",
      "config": {},
      "target": { "extensionKey": "wf:viewport", "value": "" }
    }
  ],
  "timelines": [
    {
      "triggerMetadata": { "role": "mouseX" },
      "actions": [
        {
          "id": "act-mouse-x",
          "name": "Follow X",
          "tt": 2,
          "timing": { "duration": 0.4 },
          "properties": { "wf:transform": { "x": ["0px", "40px"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    },
    {
      "triggerMetadata": { "role": "mouseY" },
      "actions": [
        {
          "id": "act-mouse-y",
          "name": "Follow Y",
          "tt": 2,
          "timing": { "duration": 0.4 },
          "properties": { "wf:transform": { "y": ["0px", "40px"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}

Example 6: Custom JS event

User: "Play this when my script fires an event"

Three non-obvious requirements, all mandatory:

  1. The trigger target must be wf:body (value: ""). Any other key is rejected.
  2. The event name lives at config.pluginConfig.eventName. At config level it is silently discarded and the trigger can never fire.
  3. It is not a DOM CustomEvent. Dispatching one does nothing. The site fires it through the IX3 module.
json
{
  "name": "Custom event flash",
  "triggers": [
    {
      "extensionKey": "wf:custom",
      "config": {
        "control": "play",
        "pluginConfig": { "eventName": "my-event" }
      },
      "target": { "extensionKey": "wf:body", "value": "" }
    }
  ],
  "timelines": [
    {
      "actions": [
        {
          "id": "act-custom-flash",
          "name": "Flash",
          "tt": 2,
          "timing": { "duration": 0.4 },
          "properties": { "wf:transform": { "opacity": ["100%", "20%"] } },
          "targets": [
            { "extensionKey": "wf:class", "value": ["STYLE_BLOCK_ID"] }
          ]
        }
      ]
    }
  ]
}

Tell the user how to fire it. This is the part that looks broken otherwise:

js
const wfIx = Webflow.require('ix3');
wfIx.emit('my-event');

emit is the only firing method — the module exposes {getInstance, emit, destroy, ready, instance}. There is no trigger, dispatch, or fire. A non-string or whitespace-only eventName is rejected at the write boundary; an absent one is accepted for backward compatibility and yields a trigger that never fires. See references/trigger-custom.md.

Guidelines

Read this one first: unknown config keys are silently discarded

triggerConfigSchema is a non-strict Zod object, so any key you put on trigger.config that is not a declared field is dropped without an error. The write succeeds, the response looks clean, get_interaction round-trips byte-identically, and your field is gone.

Declared config fields: control, delay, jump, speed, controlType, scrollTriggerConfig, pluginConfig, assignedGroupId, assignedTimelineRole, conditionalLogic.

Everything plugin-specific goes inside config.pluginConfigeventName, eventMode, multiTimeline, smoothness, and anything else a plugin defines. Putting one of those at config level is the single most expensive mistake on this surface, because nothing tells you:

jsonc
// WRONG — silently discarded, trigger never fires
"config": { "eventName": "my-event" }
// RIGHT
"config": { "pluginConfig": { "eventName": "my-event" } }

A dogfood run lost three fields this way (eventName, eventMode, and a mistyped easing) and misfiled all three as missing API features. If a field you sent is absent from the response, assume you addressed it wrong before you conclude it is unsupported.

Easing: timing.ease is a number, not a string

ease is either a non-negative integer index into the built-in easing table or an advanced-ease object. A string is rejected with a bare Invalid input that does not tell you this.

IndexNameIndexNameIndexName
0none (Linear)11power4.out22elastic.in
1power1.in12power4.inOut23elastic.out
2power1.out13back.in24elastic.inOut
3power1.inOut14back.out25expo.in
4power2.in15back.inOut26expo.out
5power2.out16bounce.in27expo.inOut
6power2.inOut17bounce.out28sine.in
7power3.in18bounce.inOut29sine.out
8power3.out19circ.in30sine.inOut
9power3.inOut20circ.out
10power4.in21circ.inOut

The panel's "Power 1 out" is 2; its "Linear" is 0.

json
"timing": { "duration": 0.4, "ease": 2 }

Advanced eases are objects discriminated on type. Just write one — no guard rejects an advanced ease at the write boundary, and ff-styl-1612-ix3-advanced-easing is public at 100% and classified stale, so it gates the panel's Adaptive Easing control rather than the API: back {curve,power}, elastic {curve,amplitude,period}, steps {stepCount}, rough {templateCurve,points,strength,taper,randomizePoints,clampPoints}, slowMo {linearRatio,power,yoyoMode}, expoScale {startingScale,endingScale,templateCurve}, customWiggle {wiggles,wiggleType}, customBounce {strength,squash,endAtStart}, customEase {bezierCurve}. curve is in / out / inOut.

json
"timing": { "duration": 0.4, "ease": { "type": "back", "curve": "out", "power": 1.7 } }

timing.stagger.ease takes the same shape. A 4-number bezier array is not accepted — use {type: "customEase", bezierCurve: "..."}.

Class change: wf:class

One property, named class, Set-only (tt: 3):

json
"tt": 3,
"properties": {
  "wf:class": {
    "class": { "operation": "addClass", "selectors": ["STYLE_BLOCK_ID"] }
  }
}

operation is addClass / removeClass / toggleClass. A bare array, a bare string, or {add: [...]} are all rejected.

stagger is an object

{amount?, axis?, each?, ease?, from?, grid?}. A bare number is rejected.

  • each / amount — seconds, or a "250ms" string
  • axis'x' / 'y'
  • ease — the same index-or-object shape as timing.ease
  • from'start' | 'center' | 'end' | 'edges' | 'random', a number, or null
  • grid'auto', a [columns, rows] number pair, or null. 'none' is rejected.
json
"timing": { "duration": 0.5, "stagger": { "each": 0.05, "from": "start", "grid": [2, 2] } }

filterContext.relationship enum

none · within · direct-child-of · contains · direct-parent-of · next-to · next-sibling-of · prev-sibling-of. CSS-flavoured guesses like descendants are rejected.

Target value shapes

KeyValue
wf:classstyle-block id array, or a class name string
wf:inst[componentId, elementId] — for a page-level element the componentId slot is the page id. Which form is legal follows scope: component scope takes the component-definition id, site and pages scope require the page id
wf:selectora CSS selector string, e.g. "body". This is how you target the body from an action; wf:body is trigger-context-only
wf:body, wf:viewport""trigger targets only
wf:any-element"*"not "". Action targets only
wf:trigger-only, wf:trigger-only-parent"". Action targets only
wf:attributean attribute name or a full selector. "data-thing" is stored as [data-thing] and matches every element carrying it; pass '[data-thing="x"]' when several elements share the attribute

wf:id is not a target key. It is refused in every context with target type "wf:id" is not offered by the Designer (shouldShow: false in all contexts). Use wf:selector, wf:class, or wf:inst instead. To target by DOM id, use wf:selector with '#the-id'. There are seven authorable target keys, not eight.

wf:any-element is the one key whose value is a wildcard, not a placeholder. The three action-only keys look interchangeable and are not: "" on wf:any-element is refused with "wf:any-element" value must be "*". Because these are usually authored one row per filterContext relationship, getting it wrong loses the whole batch rather than one row.

wf:inst and wf:trigger-only reject an active filterContext; the stamped relationship: 'none' placeholder is fine.

Caps

Triggers per interaction 20 · timelines per interaction 5 · actions per timeline 200 · targets per action 20 · canvasDuration ≤ 12s · groupId 1–64 chars · random-array sets 2–12 values · IX3 value total 65,536 bytes.

MCP applies these as Zod .max() on create and update. The Designer host raises the ceiling for already-stored over-cap interactions; MCP does not. So reading a 200+-action timeline and resubmitting it to change one action fails over MCP.

Tool-schema errata

The data_interactions_tool JSON Schema currently describes conditionalPlayback as {"type": "object"} with the example {reducedMotion:"skip"}. Both are wrong. The server validator requires an array; see the conditionalPlayback bullet below. Trust this skill over the tool description on that field.

Also note siteId/pageId placement differs across the tool family: top-level for data_interactions_tool, data_style_tool, and data_element_tool; nested site_id inside the action for data_pages_tool, data_assets_tool, and data_agent_instructions_tool.

Everything else

  • Object format only: { extensionKey, value, filterContext? }. Nested filterBy is a 2-tuple ["wf:class", ["STYLE_BLOCK_ID"]], never an object.
  • IDs: omit timeline id on create (the host mints it). Every action needs a fresh unique id. Do not send trigger id.
  • Opacity lives under wf:transform (e.g. ["0%","100%"]), never wf:style.
  • Class targets: style-block id arrays preferred; class name strings are accepted.
  • A wf:class id array is ANDed into one compound selector, not a list of alternatives: ["a","b"] resolves to .a.b. The host expands a combo class's parents for you, so pass the one leaf id. Ids from two different combo chains produce a selector no element carries, and the target resolves to nothing with no error. One target means one class or one combo chain — animate two different sets of elements with two actions.
  • A leaf class name reused across chains (.btn.lift and .btn.cta.lift are two style blocks both named lift) is rejected when passed as a name string and matches the wrong element when passed as ids. Give each element its own class instead.
  • Send only what the Interactions panel can author. Worker Zod is lenient; illegal shapes fail on the Designer write path.
  • conditionalPlayback is an array of { type, behavior } (and breakpoint form), not {reducedMotion:"skip"}. Example: [{ "type": "prefers-reduced-motion", "behavior": "dont-animate" }].
  • Load omits trigger target and pluginConfig.
  • Scroll and mouse-move omit playback control / delay / jump / speed.
  • A scroll without scrub needs enter: "play". The host stamps absent toggle keys on persist (enter: "play", the rest "none") and keeps an explicit "none", but send enter yourself: the panel always writes all four toggles, and being explicit is correct regardless of where that stamp has rolled out. scrollTriggerConfig itself is never invented — missing or null is rejected.
  • Scroll scrub: send numeric scrub, canvasDuration: 1, and action timing.duration: 1. A tiny duration occupies ~1% of the scroll range and looks like nothing happened.
  • control: "reverse" is a no-op on the first click (the playhead starts at 0). For a reverse the user can try in Preview, use togglePlayReverse (pluginConfig.click omitted or "each").
  • A To tween reads only its to slot. [from, to] on tt: 0 is half discarded and GSAP animates from the element's live value, so scale: [0.55, 1.15] on an unscaled element goes 1 → 1.15 and opacity: ["20%","100%"] on an opaque element does nothing at all. Use tt: 2 whenever the animation needs a start value.
  • Do not put a click or hover trigger on an element its own from-state collapses (scaleX: 0, width: 0). No box is left to click. Trigger on a parent and animate the child.
  • Grouped timelines are authorable. groupId (1–64 characters) is accepted on the MCP timeline input, and config.assignedGroupId on a click or hover trigger routes to it. An assignedGroupId that matches no timeline groupId is rejected rather than stored inert, so mismatches surface as an error instead of a dead interaction. Load, scroll, and continuous triggers ignore assignedGroupId.
  • wf:navbar and wf:dropdown are not authorable, and neither are wf:focus, wf:blur, or wf:change. The guards take no flag or session argument, so this holds for every caller regardless of Statsig state — no flag turns it on for you. Navbar and dropdown are registered in the Designer and excluded pending GA; focus, blur, and change have no Designer schema at all. Tell the user the trigger is unavailable rather than attempting a write. See references/gated-capabilities.md.
  • No GSAP position operators (+=, <, >) in timing.position. Use a finite number (seconds) or '500ms'.
  • Duration is seconds. timing.duration: 0.4 is 400ms. 400 is 400 seconds. Use "400ms" if you think in milliseconds.
  • From / FromTo (tt: 1 / 2) sit at the from-state until the trigger fires. Prefer To (tt: 0 or omit) when the element should be visible at rest.
  • splitText needs a target that already contains copy — in itself or a descendant. The element type is not the constraint: a Block / Div whose text is a child node splits fine (verified on a published page: a .pg-card Block containing "Card one" produced two gsap_split_word spans). What fails silently is a target with no text anywhere inside it — that saves and animates nothing.
  • Roles live on timelines[].triggerMetadata, not on the trigger. Mouse-move needs a unique mouseX / mouseY / interval per timeline.
  • On reject: read the error, look it up in references/rejects-index.md, do not invent fields.

Install / gate

npx -y skills add webflow/webflow-skills --skill 'webflow-mcp:interactions' --yes

Optional --agent claude-code (or cursor). Testers also need:

  • Beta MCP: https://mcp.webflow.com/beta/mcp
  • Flag: ff-ix3-interaction-apis
  • Scopes: pages:read / pages:write

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Webflow Mcp:Interactions AI skill do?

Create, update, list, and delete Webflow IX3 interactions (GSAP animations) through Webflow MCP. Use when the user wants click/hover/load/scroll/mouse-move animations, interaction timelines, or data_interactions_tool / create_interaction payloads. Requires beta MCP + ff-ix3-interaction-apis during dogfood.

Why use Webflow Mcp:Interactions on TypingMind?

Because you install it once and use it with any model. Webflow Mcp:Interactions is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Webflow Mcp:Interactions in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/webflow/webflow-skills/tree/main/plugins/webflow-skills/skills/interactions. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Webflow Mcp:Interactions?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Webflow Mcp:Interactions?

As many as you like. As long as a model supports skills, you can use Webflow Mcp:Interactions with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Webflow Mcp:Interactions AI skill free?

Yes. It is published on GitHub by webflow under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇