webflow-vue
v0.3.0
Published
Vue 3 islands on Webflow-rendered DOM — mount, shared state, and CMS parsing, with no build step required.
Maintainers
Readme
Webflow Vue
Vue 3 islands on Webflow-rendered DOM. Webflow owns the markup and the styling; Vue owns the behaviour. No build step required, and no page-wide takeover — the rest of the page stays untouched Webflow, with its own runtime intact.
Status:
0.3.0, unstable. The API still moves. Pin an exact version; expect signatures to change before1.0.0.
Quick start — two script tags
Paste into your Webflow page's custom code (head):
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/webflow-vue.global.js"></script>Give any element an id — that is your island. Then add an embed placed after it (a code embed's scripts run at parse time, so an embed above the island mounts nothing):
<script>
const { ref, computed } = Vue
const { mountIsland } = WebflowVue
mountIsland('#counter', 'counter', () => {
const cups = ref(1)
const grams = computed(() => cups.value * 18)
return { cups, grams }
})
</script><!-- the island itself, built in the Designer -->
<div id="counter">
<button v-on:click="cups++">+</button>
<span>{{ cups }} cups = {{ grams }} g</span>
</div>vue.global.js is required rather than the runtime-only build: an island's
template is the live Webflow DOM, so the template compiler has to be present.
The bridge (route 2)
Building a project rather than pasting islands into a page? One tag in the page's footer custom code loads everything:
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bridge.global.js"
data-bundle="https://cdn.prod.website-files.com/<site-id>/<asset-id>_main.txt"
></script>It picks, in the browser, where your island code comes from:
| URL | loads |
| --- | --- |
| normal | Vue → webflow-vue → your built bundle, in that order |
| ?debug | your local Vite dev server, with HMR, and no CDN copies |
The version in that src is the only version pin: the bridge loads the
webflow-vue build that shipped beside it, derived from its own URL, so the page
and the bundle cannot drift apart.
Optional: data-staging-bundle (a different bundle for *.webflow.io),
data-dev, data-vite-client, data-vue, data-library, data-debug-param.
Omit data-bundle and the page tells you so in the console rather than fetching
a 404 in silence.
CLI
npx webflow-vue detect <url> # read a published page and report what it is running
npx webflow-vue init [dir] # scaffold a project, when a page is not enoughdetect fetches the published page and tells you which of the two delivery
routes it is on — CDN script tags in page custom code, or a bridge backed by a
project bundle — along with the pinned versions, the mountIsland() calls it can
read, and the problems that are visible without running anything:
$ npx webflow-vue detect https://my-site.webflow.io/brew
route 1 — CDN tags in the page's own custom code. No project needed.
vue 3 — https://unpkg.com/vue@3/dist/vue.global.js
webflow-vue 0.3.0 — https://cdn.jsdelivr.net/npm/[email protected]
mounts brew → [data-brew]
markup 3 mustache(s), 1 directive(s)
1 warning(s)
! foreign-directive
v-expand is not a Vue directive — Vue fails with "Failed to resolve
directive" and the whole island renders nothingIt catches, among others: both routes installed at once (two Vue instances that
do not share reactivity), a bridge still carrying its SITE_ID placeholders,
{{ mustaches }} on a page that loads no Vue — the usual symptom of island
markup living inside a Webflow component that ships site-wide — {{ x.value }},
v-* attributes belonging to some other library, and a <script src> that got
line-wrapped and 404s in silence.
Run it before deciding anything, and again after publishing. It takes a local file path as well as a URL.
Agent skill
npx skills add brmbh/webflow-vueInstalls webflow-vue-scaffold, which drives the Webflow MCP to build islands for
you. It runs detect first and branches on the answer: a page already on route 1
gets its custom code edited in place; only route 2 gets a bridge, a bundle and a
dev server.
API
mountIsland(target, label, setup) // mounts EVERY match; returns an array of apps
unmountIsland(target) // tears them down; returns how many
useSharedStore(name, initial, opts) // named reactive singleton, optional persistence
useWebflowCMS(options) // parse rendered Collection Lists into reactive data
fetchCollection(url, options) // fetch an item's template page, cached and deduped
loadAllPages(collections, options) // walk Webflow's pagination past the 100-item limit
useFinsweetList(options) // read items from Finsweet Attributes instead
cleanDOMForVue(root, label) // detach/restore Webflow runtime nodes around a mountsetup is Vue's setup(). Whatever object it returns becomes the vocabulary
your Designer markup can reference; anything not returned stays private. It is
called once per element and receives (el, index).
One element or many — same call
An island is a component definition; it may appear in several places.
mountIsland mounts every element that matches, so #counter matching once and
[data-brew] matching three times are the same operation. Nothing can silently
under-mount.
mountIsland('#counter', 'counter', setup) // → [app]
mountIsland('[data-brew]', 'brew', setup) // → [app, app, app]Prefer an attribute over IDs when a component repeats: adding another mount
point is then a Designer action with no code change. target also accepts an
element or a list of elements.
Where to declare things
The callback runs once per island, and that single fact decides everything:
| declared | outside the callback | inside the callback |
|---|---|---|
| plain const | once | once per island — harmless |
| computed over shared state | 1 evaluation | N evaluations, same values |
| ref | shared by every island | independent per island |
| anything using el / index | not available | required |
outside = properties of the thing · inside = properties of this instance
const cups = ref(1) // shared by every island
const grams = computed(() => cups.value * 18) // evaluated once
mountIsland('[data-brew]', 'brew', () => ({ cups, grams }))mountIsland('[data-bean]', 'bean', (el) => {
const qty = ref(1) // this card's own
const price = Number(el.dataset.price) || 0 // this card's own
return { qty, price, total: computed(() => qty.value * price) }
})You probably do not need useSharedStore
Within one script block, a ref declared outside the callback is already shared
by every island — they close over the same object. The store earns its place in
three cases only:
- Separate
<script>blocks or embeds, which share no lexical scope. - Separate files you would rather not couple with imports.
- Persistence —
{ persist: true }survives a page load or a client-side navigation. A plain ref resets.
Page transitions (barba, swup, Turbo)
The container is replaced, so islands inside it are destroyed and the fresh markup is never mounted. Re-run the mounts after each navigation:
function mountAll() { /* all your mount calls */ }
mountAll()
if (window.barba) barba.hooks.afterEnter(mountAll)mountIsland returns the existing app for any root it has already mounted, so
re-running is safe. State resets across a transition because the elements are
new — use a persisted store for anything that must survive.
Why islands
Wrapping a whole Webflow page in one Vue app breaks Webflow's runtime: Vue's compiler rebuilds the mount target, destroying lightbox JSON configs (script.w-json), slider DOM, and native interactions. Webflow Vue mounts one createApp per interactive component on its own root element — everything outside the islands stays untouched Webflow DOM.
Setup
npm install
npm run dev # https://localhost:3000 — local dev harness with HMR
npm run build # dist/main.js — demo app bundle, for Webflow asset upload
npm run build:lib # dist/webflow-vue.global.js + .esm.js — the library
npm test # vitest + jsdommkcert generates a trusted local cert on first run so the dev server can be loaded into HTTPS Webflow pages without mixed-content errors.
Architecture
- Vue runtime loaded from CDN (
vue.global.js, ships the template compiler). In dev, Vite aliasesvue→vue/dist/vue.esm-bundler.js(seevite.config.js) — islands compile the live Webflow DOM as their template, so the runtime compiler must always be present. - App code in
src/, bundled with Vite as a UMDmain.js. Vue is externalized to the CDN global in builds. - Mount strategy:
mountIsland(target, label, setup)(src/mountIsland.js) — NO template option; Vue uses the live Webflow-rendered DOM as its template. Islands skip themselves when their mount point isn't on the current page, so one bundle serves the whole site. - Bridge script (
webflow-bridge.html) goes in Webflow page custom code. Routes the bundle source by env:?debug→ local Vite with HMR,*.webflow.io→ staging asset, prod host → prod asset. Apply at page level only — site + page double-mounts Vue.
Core modules
| Module | Job |
| --- | --- |
| src/mountIsland.js | One createApp per island, pre-mount sweep, error handler, timing logs |
| src/composables/useSharedStore.js | Cross-island state: named module-scoped reactive() singletons, optional sessionStorage persistence for cross-page state |
| src/composables/useWebflowCMS.js | Parses Webflow-rendered Collection List data into a reactive collections object |
| src/utils/cleanDOMForVue.js | Rescues script.w-json configs and strips <style> blocks inside a mount target before Vue compiles it |
Shared state between islands
const store = useSharedStore('cart', { items: [] }, { persist: true });Every island calling useSharedStore('cart') gets the same reactive object — module scope is the singleton boundary, no Pinia needed. With persist: true the state survives reloads and page navigation (badge in the navbar keeps its count on every page).
Conventions (verified live)
- Custom Element is the default scaffolding primitive. Create elements via
element_buildertype: "DOM"+set_dom_config: { dom_tag: "..." }— including plaindivs. Pick the semantically correct tag (real<button>, not<a href="#">). - Long-form Vue directives only on Webflow-rendered DOM:
v-on:click✅ (@click❌),v-bind:class✅ (:class❌).v-modelworks. Inside.vueSFCs shorthand is fine. - CMS data — two conventions, freely mixable per item (both parsed by
useWebflowCMS(); grouping key is always the attributedata-field-collection="beans"):- Attribute fields —
data-field-name="…"on the Collection Item element. Designer-authored; bind values by hand in the Designer. - Field elements —
<span data-field="name">…</span>children whose text is CMS-bound. This is the API-scaffoldable variant: the Webflow MCP can bind CMS fields to element text (set_settingskey"text"+ cms binding) but not to attribute values.
- Attribute fields —
- Collection List via MCP: the list's
sourcesetting takesstatic_json{"collectionId":"…"}on the DynamoWrapper element. - Styles via MCP:
set_style'sstyle_namesarray resolves as a combo-class chain, not stacked globals — register shared modifiers (e.g.gutter-medium) as combos under each base class. - Bundle hosting: Webflow assets reject
.js— uploaddist/main.jsrenamed tobundle.txt; the asset CDN serves it withoutX-Content-Type-Options, so a classic<script src>executes it. Update the bridge by re-registering the samedisplayNamewith a bumped version (update_registered_script404s) and re-applying viaset_page_scripts. - Known MCP limits: native Lightbox media items aren't writable (Designer only);
set_textHTML-escapes (no JSON payloads — use an HtmlEmbed'scodesetting); body elements reject classes (scope body styles withbody:has(#your-island)CSS in head code).
Case studies (live on the sandbox)
Both wireframe-styled, built 100% through the Webflow MCP from one chat session, console.log-instrumented ([webflow-vue:*] prefixes) and verified by driving a real browser.
- Webflow Vue Islands —
/vue/vueflow-islands?debugThe mechanics: counter island + status island sharing one store across page regions, CMS filter island, sessionStorage hydration,cleanDOMForVuerescuing a lightbox config. - Brew Lab —
/brew-lab?debug+/brew-lab-about?debugThe product-shaped demo: bean grid from a real, MCP-bound CMS Collection List with reactive facets; subscription configurator (grind/size/frequency → live price + discount); navbar cart badge that persists across pages; origin island fetching live Open-Meteo weather for the selected bean's growing region. DOM classed per the Semantic Framework (composition owns spacing, BEM components,is-*combos) so a design pass needs zero DOM/code changes.
Demo flow
- Open the Webflow page with
?debug→ bridge loads Vue from CDN + bundle fromlocalhost:3000with HMR - Webflow Designer open on the same page → enables Designer-side MCP calls (headless
data_*tools work without it) - Ask Claude: scaffold a variable + matching DOM. Claude edits
src/AND inserts Custom Elements via MCP, then publishes - Vite HMR auto-reloads the live page (the scaffold skill
touchessrc/main.jsafterpublish_site) → code + DOM changes appear together
Stakeholder demo runbook: demo/filter-showoff.md
Companion Skills
Install the upstream skill pack:
claude plugin marketplace add webflow/webflow-skills
claude plugin install webflow-skills@webflow-skills- Required:
webflow-skills:custom-code-management— bridge registration + page apply - Recommended:
safe-publish,site-audit,link-checker - Local:
.claude/skills/webflow-vue-scaffold— orchestrates bridge install, code + DOM co-evolution, publish + auto-reload
Planned / Wanted
webflow-vue-data-bridge— scaffold a CMS-bound reactive list end-to-end (the Brew Lab beans pipeline, generalized)webflow-vue-verify— diff the Vue contract (mount selectors,v-*directives, refs) against live Webflow DOM, report drift
