Designer Extensions and the Designer API
|
This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production. This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages. |
This page describes the Webflow Designer Extension platform and the Designer API as they were on 2026-10-28, the date on
which its facts were checked against the official developer documentation. The examples use the Webflow CLI
@webflow/webflow-cli 2.15.0 (which needs Node.js 22.13 or later), the typings package
@webflow/designer-extension-typings 2.2.2 and, in the sections that touch the Data API, the JavaScript SDK webflow-api
3.3.4. Plans are named by tier only. Building and testing an extension needs a workspace in which you can register an app
(see Apps Overview and OAuth), and every example runs against a site in a
free Developer workspace, created as explained in
Developer Platform Overview.
The running example is Fernbrook Gardens, the fictitious botanical garden of the whole sub-section. The garden’s volunteers print a label for every plant bed, and the site is localized in Spanish and German. The extension built on this page, the Plant Label Kit, is a panel inside the Designer that stamps out a consistent label card, keeps the brand colors in variables with a dark mode, stores label images as assets with alt text in each locale and reports problems to the designer with notifications rather than with silent failures.
What a Designer Extension is
A Designer Extension is a small web application that Webflow loads in an iframe inside the Designer, in the panel that
opens from the Apps icon. The iframe is an ordinary web page: it has its own HTML, CSS and JavaScript, runs in the
browser of the person using the Designer and can call any public web API the browser can reach. What makes it an
extension is a global object, webflow, that Webflow injects into the page. That object is the Designer API, a set of
client-side JavaScript APIs that execute in the browser through the iframe and let the code create and edit elements,
styles, variables, components, pages, assets and interactions on the canvas the user is working on.
Two consequences shape every design decision.
-
The extension acts as the user who has the Designer open. It does not use a token and it has no scopes: what it may do depends on the user’s role, the current page, locale, branch and Designer mode. The section on app modes below explains how to ask what is allowed before trying.
-
The extension cannot see the site’s data: the Designer API manipulates the design, not the CMS content or the form submissions. Anything that needs the Data API (CMS items, publishing, forms) needs a backend and an OAuth token, which is the subject of Hybrid Apps. This page stays inside the iframe.
Source: Designer API: Introduction
The Designer API is organized into areas, each with its own pages in the reference.
| Area | What it covers |
|---|---|
Elements |
Select, create, insert, move and remove canvas elements; read and set their properties, text, attributes and styles |
Styles |
Create, find and edit the reusable classes of the site, including combo classes, per breakpoint and pseudo state |
Variables and collections |
Define global values for colors, sizes, numbers, percentages and font families, and the modes that switch them |
Components |
Create components and their variants and props, and add component instances to the canvas |
Pages and folders |
Read and set page names, slugs, titles, SEO and Open Graph settings |
Assets |
Upload, list, rename and describe the files of the site’s Assets panel, including alt text per locale |
Interactions |
List, read, create, set and remove interactions that use GSAP |
Extension utilities |
Resize the extension, read site information, the current mode and breakpoint, and the launch context |
Events |
Subscribe to what the user does in the Designer, and send notifications to the user |
Source: Designer API: Introduction
Scaffolding an extension
The Webflow CLI creates the project. It is installed globally with npm:
npm install --global @webflow/webflow-cli@2.15.0
webflow --version
Source: Webflow CLI Reference
The same tool also covers the DevLink and Webflow Cloud workflows, which have their own pages: see
Webflow CLI. This page uses only the extension command group.
webflow extension init and the templates
webflow extension init takes a project name and an optional template. webflow extension list prints the templates the
installed CLI knows about, so the list is always the one that matches the version in use.
webflow extension list
webflow extension init plant-label-kit react
cd plant-label-kit
npm install
Source: Webflow CLI Reference
The official guide scaffolds a React project, and it notes that TypeScript-based templates exist. The command creates a directory with the following structure:
plant-label-kit/
├── node_modules/
├── public/ # files that serve the extension
│ ├── index.html # entry point
│ ├── index.js # interactivity and behavior
│ └── styles.css # visual appearance
├── src/
│ └── index.ts # source code
├── package-lock.json
├── package.json
├── webflow.json # extension settings
├── README.md
└── tsconfig.json # TypeScript configuration
The folder named public is what the dev server serves and what the bundle command packs. Everything the iframe needs,
HTML, built JavaScript and CSS, must end up in that folder, whatever the build tool is. The scripts that the template puts
in package.json build the TypeScript from src into public; the Plant Label Kit keeps that arrangement and only
adds the typings package, covered in a later section.
Because the panel is an ordinary web page, any front-end stack works: plain TypeScript as in this page, React as in the
template, or any bundler that outputs static files into publicDir. Choose the stack the team already knows; the Designer
API is the same in all of them.
The webflow.json manifest
Every extension has a webflow.json file in the project root. It tells Webflow how to present the extension and which
parts of the Designer may connect to it.
| Field | Required | Meaning |
|---|---|---|
|
Yes |
The name of the app; descriptive and unique |
|
No |
The version of the Designer APIs the app uses; the documentation recommends |
|
No |
The initial size of the extension window: |
|
No |
The directory built and served; defaults to |
|
No |
The element types that can create connections to the app, each with a list of intents |
|
No |
Unique identifiers of the connections between the app and canvas elements |
|
No |
Boolean switches for extra behavior; the supported key is |
The manifest of the Plant Label Kit asks for the comfortable size, because the panel holds a form with a handful of fields, and declares one app intent so that an image element in the Designer shows a Connections entry that opens the extension:
{
"name": "Plant Label Kit",
"apiVersion": "2",
"size": "comfortable",
"publicDir": "public",
"appIntents": {
"image": ["manage"]
},
"appConnections": ["manageLabelImage"],
"featureFlags": {
"expandedAppModes": true
}
}
Every field deserves a closer look.
size
The three named sizes are tuned for typical panels: default for a single button or a short list, comfortable for forms
such as the label form and large for previews and multi-step workflows. At runtime webflow.setExtensionSize() accepts
the same names or an object with a width and a height, as long as the result stays between 240 by 360 and 1200 by 800
pixels. The runtime call is how an extension grows for a preview and shrinks again afterwards; a section below shows it.
appIntents and appConnections
An app intent is a declaration that the extension wants to be offered on a kind of element. The documentation lists the
image, form and form wrapper element types as supported, and the example keys are image and form with the intent
manage. Once the manifest is in place, the app appears in the Connections section of the settings panel of those
elements. When the user launches the extension from there, webflow.getLaunchContext() returns an object whose type is
AppIntent and whose value carries the intent, for example image with manage.
An app connection is the durable link that the extension then creates between itself and one element: the extension
calls element.setAppConnection() with one of the identifiers declared in appConnections, and Webflow adds a button to
that element’s settings panel that launches the extension again. On that second launch the launch context has type
AppConnection and the identifier as value, so the extension can route straight to the right screen.
const context = await webflow.getLaunchContext();
const element = await webflow.getSelectedElement();
if (context?.type === "AppIntent" && context.value?.image === "manage" && element) {
await element.setAppConnection("manageLabelImage");
showScreen("image-alt-text");
} else if (context?.type === "AppConnection" && context.value === "manageLabelImage") {
showScreen("image-alt-text");
}
Source: App Intents and Connections
The launch context is explained further, with the other launch types, in Hybrid Apps.
featureFlags and expandedAppModes
By default a Designer Extension is closed when the user leaves the Design mode. Setting expandedAppModes to true keeps
the extension open when the user moves between Designer modes, which is what makes the app-mode checks described below
useful. Setting it to false opts out, and the app closes when the user leaves Design mode.
Source: Designer API: App Modes
Serving locally and launching in the Designer
The development loop has two halves: a local web server that serves public, and a Designer that loads the extension from
that server.
npm run dev
In a project created with init, the dev script runs webflow extension serve, which serves the extension on port 1337,
and runs webpack in watch mode next to it so that edits to src are rebuilt into public. The command can be run directly
with another port when 1337 is taken:
webflow extension serve --port 1338
Source: Webflow CLI Reference
To launch the development version inside the Designer:
-
Register the app in the workspace (Workspace settings > Apps & Integrations > Develop) with the Designer Extension capability. The registration steps are in Apps Overview and OAuth.
-
In Apps & Integrations > Develop find the app, open its … menu, choose Install and install it on the test site, here the Fernbrook Gardens developer site.
-
Open the test site in the Designer and press the
Ekey to open the Apps panel. -
Choose Launch development app. If the app does not appear, refresh the page.
The panel then loads the page served by the local server. Edits to the TypeScript rebuild into public, and reloading the
panel picks them up. A change to webflow.json, such as a new app intent, is only read when the app is reinstalled or
re-bundled, so restart the server after editing it.
Typings and project setup for TypeScript
The webflow global is typed by the npm package @webflow/designer-extension-typings. Installing it as a development
dependency and listing it in types gives the editor completion for every method on this page and makes the compiler
reject calls with the wrong shape:
npm install --save-dev @webflow/designer-extension-typings@2.2.2 typescript
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"lib": ["ES2022", "DOM"],
"types": ["@webflow/designer-extension-typings"],
"outDir": "public",
"rootDir": "src"
},
"include": ["src"]
}
Two practical points follow from the typings. First, nearly every method returns a promise, so every call needs await
and an enclosing async function; a forgotten await is the most common reason a change seems to do nothing. Second,
the types for elements form a union (AnyElement) whose members expose different methods, so the code narrows with the
type property (for example element.type === "Image") or with a guard on a property such as textContent before it
calls a method that only some elements have.
The ability model
Before the individual methods, one rule applies to all of them. Each method is documented with a Designer ability and a set of conditions: which locale (primary or any), which branch (main or any), which workflow (canvas or any) and which site mode. For example, creating a style needs the ability to create styles in the primary locale, in the canvas workflow and in Design mode, while reading page settings works in any locale, branch and mode.
A call made outside those conditions does not fail silently: it rejects with an error whose cause tag is Forbidden (the
user lacks the role, locale or branch) or ModeForbidden (the Designer is in the wrong mode). The sections on app modes
and error handling show how to ask first and how to answer the rejection.
Elements
Elements are the nodes of the canvas. The extension reaches them through the selection or through a page-wide listing.
const selected = await webflow.getSelectedElement(); // null when nothing is selected
const everything = await webflow.getAllElements(); // every element of the current page
await webflow.setSelectedElement(everything[0]); // select programmatically
if (selected?.children) {
const children = await selected.children;
console.log(`The selection has ${children.length} children`);
}
Source: Creating and Retrieving Elements
Inserting elements
Four methods on an existing element place a new element relative to it, and each accepts an element preset (or a component, see below):
-
before(preset)inserts a sibling before the target, under the same parent. -
after(preset)inserts a sibling after the target. -
prepend(preset)inserts the new element as the first child of the target. -
append(preset)inserts it as the last child.
Each returns the new element, so the next step can configure it. element.remove() deletes an element; a later call on a
removed element throws.
const bed = await webflow.getSelectedElement();
if (bed) {
const card = await bed.append(webflow.elementPresets.DOM);
await card.setTag("article");
}
Source: Creating and Retrieving Elements
Element presets
webflow.elementPresets is a catalog of the element types that can be created with one call. It includes the basic types
(Heading, Paragraph, TextBlock, Image, Button, TextLink, LinkBlock, List, ListItem, RichText,
Blockquote, CodeBlock, HtmlEmbed, Video, YouTubeVideo), the structural ones (Section, Slot, Grid,
HFlex, VFlex, DOM), form parts (FormForm, FormTextInput, FormTextarea, FormSelect, FormCheckboxInput,
FormRadioInput, FormButton), widgets (NavbarWrapper, TabsWrapper, SliderWrapper, DropdownWrapper,
LightboxWrapper, MapWidget) and many ready-made layout sections and Ecommerce elements. Presets such as BlockContainer,
DivBlock, Grid, HFlex, Row, Section and VFlex are marked deprecated in the reference, so new code uses the
generic DOM preset and sets the tag and the styles itself. Not every element type can be created through a preset; the
documentation points to a separate custom-element method for the rest.
Source: Element Presets
The element builder
Calling append repeatedly makes one round trip to the Designer per element and leaves the canvas half built if one call
fails. The element builder assembles a whole structure in memory and adds it to the canvas in a single operation.
webflow.elementBuilder(webflow.elementPresets.DOM) returns a root builder; each builder has setTag, setAttribute,
setTextContent, setStyles and append, and append returns the child builder so the code can configure it. Only DOM
elements are supported by the builder.
function buildLabelCard(plant: { common: string; botanical: string; bed: string }) {
const card = webflow.elementBuilder(webflow.elementPresets.DOM);
card.setTag("article");
card.setAttribute("data-bed", plant.bed);
const title = card.append(webflow.elementPresets.DOM);
title.setTag("h3");
title.setTextContent(plant.common);
const latin = card.append(webflow.elementPresets.DOM);
latin.setTag("p");
latin.setTextContent(plant.botanical);
const bed = card.append(webflow.elementPresets.DOM);
bed.setTag("p");
bed.setTextContent(`Bed ${plant.bed}`);
return card;
}
const parent = await webflow.getSelectedElement();
if (parent?.children) {
await parent.append(buildLabelCard({ common: "Lavender", botanical: "Lavandula angustifolia", bed: "C-14" }));
}
Source: Bulk Add Elements
The documentation’s best practices for the builder are to build the whole structure before appending it, to keep references
to child builders that need later changes and to use Promise.all when modifying several elements at once.
Text, attributes and the DOM tag
element.setTextContent(text) replaces the text of an element that supports text and needs the edit ability in the canvas
workflow. Guard the call with a check of element.textContent, because the method does not exist on elements such as an
image. The related methods in the same reference cover custom attributes (setCustomAttribute), the HTML tag of a DOM
element, the heading level, the alt text of an image element (element.setAltText(), which needs the ability to modify image
elements) and link settings. A DOM element is a plain HTML element whose tag and attributes the extension chooses.
Source: Set Text Content
Styles and combo classes
A style in the Designer API is a class of the site: a named bundle of CSS properties that elements reference. The Webflow-side behavior of classes and combo classes is explained in Classes, Combo Classes and Selectors; this section shows the API.
Creating a style
webflow.createStyle(name, options) creates a class and resolves to the Style. Passing parent in the options creates a
combo class on top of that parent. style.setProperties(properties, options) writes CSS properties, written in kebab-case
as in CSS, and the options select the breakpoint, the pseudo state or a component variant.
const base = await webflow.createStyle("label-card");
await base.setProperties({
"display": "flex",
"flex-direction": "column",
"padding-top": "16px",
"padding-right": "16px",
"padding-bottom": "16px",
"padding-left": "16px",
"border-top-width": "2px",
"border-right-width": "2px",
"border-bottom-width": "2px",
"border-left-width": "2px",
"border-top-style": "solid",
"border-right-style": "solid",
"border-bottom-style": "solid",
"border-left-style": "solid",
"font-size": "16px",
});
// Smaller type on phones: same property map, different breakpoint
await base.setProperties({ "font-size": "14px", "padding-top": "8px" }, { breakpoint: "small" });
// A hover state
await base.setProperties({ "background-color": "#f3f7ee" }, { pseudo: "hover" });
Source: Set style properties
The breakpoint option takes one of xxl, xl, large, main, medium, small or tiny, and the pseudo option one of
noPseudo, nth-child(odd), nth-child(even), first-child, last-child, hover, active, visited, focus,
focus-visible, focus-within, placeholder, empty, before or after. The breakpoints are the same cascade
described in Responsive Design and Breakpoints: the main value is
the base, and each other breakpoint overrides it for narrower or wider screens.
Combo classes
Passing the parent creates the combo class, and the lookup uses a two-element path:
const base = await webflow.getStyleByName("label-card");
if (!base) throw new Error("The label-card class does not exist yet");
const wide = await webflow.createStyle("is-wide", { parent: base });
await wide.setProperties({ "padding-left": "32px", "padding-right": "32px" });
// Later, find the combo class again: the parent name first, then the combo name
const again = await webflow.getStyleByName(["label-card", "is-wide"]);
console.log(again ? await again.getProperties() : "not found");
Source: Create style
getStyleByName resolves to null when the style does not exist, so the check above is required rather than defensive.
The reference also lists methods to read whether a style is a combo class, its parent, its selector and whether it comes
from a shared library (see Shared Libraries).
Applying styles to elements
An element receives its classes through setStyles, which takes an array. For a combo class the array lists the base
class first and the combo after it, in the order in which the Style panel shows them.
const element = await webflow.getSelectedElement();
const base = await webflow.getStyleByName("label-card");
const wide = await webflow.getStyleByName(["label-card", "is-wide"]);
if (element?.styles && base && wide) {
await element.setStyles([base, wide]);
}
Source: Set Styles
Creating a style needs the ability to create styles, which requires the primary locale, the canvas workflow and Design mode.
A name that is empty or collides with a different kind of style is rejected with the InvalidStyleName or DuplicateValue
error tags, and a property that does not exist or a value of the wrong shape with InvalidStyleProperty. Check for an
existing class first with getStyleByName to make the extension idempotent: running it twice must not create label-card
twice.
Variables and modes
Variables hold global values (colors, sizes, numbers, percentages and font families) that styles can reference, and a variable collection groups them. A collection can have modes, each carrying its own value of every variable, which is how a site offers a dark theme or a tablet-specific scale. The no-code side is in Variables and Modes.
Creating a color variable
webflow.getDefaultVariableCollection() returns the default collection of the site. collection.createColorVariable(name,
value) creates a color variable from a color name, an RGB hex value or an RGBA hex value, and resolves to the variable.
Created color variables can then be used for text, background, border and text-stroke colors and for gradient stops.
const collection = await webflow.getDefaultVariableCollection();
if (!collection) throw new Error("This site has no default variable collection");
const leaf = await collection.createColorVariable("fernbrook-leaf", "#2f6b3a");
const paper = await collection.createColorVariable("fernbrook-paper", "#faf7ef");
console.log(leaf.id, paper.id);
Source: Create color variable
The creation methods exist for each kind of variable (number, color, font family, size and percentage). They need the ability to modify variables, which works on the main branch, in the canvas workflow and in Design mode.
Modes
A mode is created with collection.createVariableMode(name, breakpointId). Without a breakpoint the mode is manual: the
site switches to it when an element or the page applies it. With a breakpoint the mode is automatic, driven by that
breakpoint. Each breakpoint can drive only one automatic mode per collection, the base mode included, and the breakpoint
must be enabled on the site; otherwise the call rejects with a VariableModeInvalid error.
const collection = await webflow.getDefaultVariableCollection();
const darkMode = await collection?.createVariableMode("Dark Mode");
const phoneMode = await collection?.createVariableMode("Phone", "small");
const found = await collection?.getVariableModeByName("Dark Mode");
console.log(darkMode?.id === found?.id, phoneMode?.id);
Source: Create variable mode
Setting a value in a mode
variable.set(value, options) writes a variable’s value, and options.mode selects the mode the value belongs to. Passing
null as the value resets the mode-specific value to its default.
const collection = await webflow.getDefaultVariableCollection();
const leaf = await collection?.getVariableByName("fernbrook-leaf");
const darkMode = await collection?.getVariableModeByName("Dark Mode");
if (leaf && darkMode && leaf.type === "Color") {
await leaf.set("#8bc79a", { mode: darkMode }); // lighter green on the dark theme
}
Source: Set variable value
Using the variable from a style (so that the label really changes color in the dark mode) is the next step: a style
property accepts a variable as its value, and the reference describes the binding of variables to style properties. The
Plant Label Kit below uses fernbrook-leaf as the border color of its cards through that route.
Components
A component is a reusable group of elements with props, slots and variants, described from the designer’s side in Components, Props, Slots and Variants. The API creates them, finds them and puts instances on the canvas.
Registering a component
webflow.registerComponent(options, root) creates a component. With no second argument it creates a blank component; with
an existing canvas element it converts that element into the component (and, by default, replaces it with an instance); with
an element builder or preset it creates the component from that structure; with another component it duplicates it. The
options hold the required name (non-empty and unique), and optionally group, description and replace.
const bed = await webflow.getSelectedElement();
if (bed) {
const labelComponent = await webflow.registerComponent(
{
name: "Plant Label",
group: "Fernbrook",
description: "Common name, botanical name and bed code for a plant label",
},
bed,
);
console.log("Registered", labelComponent.id);
}
Source: Create a component
Passing replace: true together with a preset or a builder throws InvalidRequest, because replacing only makes sense for
an element that is already on the canvas. Creating components needs the ability to create components, in the primary locale
and the canvas workflow.
Using a component
A component instance is added like any other element: the insertion methods accept a Component.
const all = await webflow.getAllComponents();
const label = all[0];
const anchor = await webflow.getSelectedElement();
if (anchor && label) {
await anchor.before(label);
}
Source: Create a component instance
The reference also covers the component’s props (create, read, set and remove a prop), variants, slots, unlinking an instance and opening a component’s own canvas; the pages marked Beta there may change before they settle.
Pages and SEO
webflow.getCurrentPage() resolves to the page the user is editing. The returned page can be a branch, and
page.getBranchId() and page.getParentPageId() tell which. The page object exposes getters and setters for the name,
slug, title and the description, and a group of methods for what search engines and social networks see: the search title
and description, the search image, the Open Graph title, description and image, the exclusion of the page from search and
the draft flag. The SEO side of the same fields is explained in SEO and AEO.
const page = (await webflow.getCurrentPage()) as Page;
await page.setTitle("Lavender in the Mediterranean bed | Fernbrook Gardens");
await page.setSearchTitle("Lavender (Lavandula angustifolia) | Fernbrook Gardens");
console.log(await page.getSearchTitle());
Source: Set search title
Both setters take string | null, and both need the ability to manage page settings, which works in any locale, branch,
workflow and mode. The setters write to the locale the Designer is currently showing, which is why the app-mode checks later
in this page matter for a bilingual site: the same call in the Spanish locale writes the Spanish title.
Assets
The Assets panel is exposed through webflow.getAllAssets(), webflow.getAssetById(id) and webflow.createAsset(file).
Each Asset has getters and setters for name, MIME type, URL, folder and alt text.
const assets = await webflow.getAllAssets();
for (const asset of assets) {
const name = await asset.getName();
const mimeType = await asset.getMimeType();
console.log(name, mimeType);
}
Source: Get All Assets
createAsset takes a File and uploads it. A remote file is fetched and wrapped in a File first; a local file comes from
an <input type="file">.
async function uploadLabelImage(url: string, fileName: string) {
const response = await fetch(url);
const blob = await response.blob();
const file = new File([blob], fileName, { type: "image/png" });
const asset = await webflow.createAsset(file); // images up to 4 MB, documents up to 10 MB
return asset;
}
Source: Create an Asset
The call only creates: to replace the file of an existing asset the reference points to a separate method that sets the
asset file. A failed creation is reported with the ResourceCreationFailed error tag, and Lottie files must be passed with
the application/json MIME type. asset.setAltText(altText, localeId) writes the alt text, with null meaning that the
image is decorative, and the optional locale ID is how a bilingual site stores a Spanish and a German description of the
same file.
const LOCALES = { es: "REPLACE_WITH_SPANISH_LOCALE_ID", de: "REPLACE_WITH_GERMAN_LOCALE_ID" };
async function describeLavender(assetId: string) {
const asset = await webflow.getAssetById(assetId);
if (!asset) return;
await asset.setAltText("Lavender flowers in bed C-14"); // primary locale
await asset.setAltText("Flores de lavanda en el bancal C-14", LOCALES.es);
await asset.setAltText("Lavendelblueten in Beet C-14", LOCALES.de);
}
Source: Set Alt Text
The getAllAssets listing plus these setters are the basis of an alt-text audit; a backend that fixes the alt text of an
entire site in bulk is built in Hybrid Apps, because it needs the Data API.
Interactions
webflow.interactions lists, reads, creates, sets and removes the interactions of the site. It works only with GSAP
interactions (see Interactions with GSAP); on a site that uses only Classic
Interactions, which are covered in Classic Interactions, getAll returns an
empty list and nothing can be written to them. Writes accept six kinds of trigger: click, hover, page load, scroll, mouse
move and custom event. Changes appear live on the canvas, can be undone with the undo shortcut and sync to collaborators.
const page = await webflow.getCurrentPage();
const created = await webflow.interactions.create({
pageId: page.id,
name: "Fade in labels on load",
scope: { type: "pages", value: [page.id] },
triggers: [{ extensionKey: "wf:load", config: {} }],
timelines: [
{
name: "Fade",
actions: [
{
id: "a1",
name: "Opacity",
timing: { duration: 0.6 },
properties: { "wf:transform": { opacity: ["0%", "100%"] } },
targets: [{ extensionKey: "wf:class", value: "label-card" }],
},
],
},
],
});
console.log("Created interaction", created.id);
Source: Create interaction
The class slug in the target must match exactly one class of the site, and the call is rejected otherwise. scope limits the
interaction to the page; without it the interaction is site-wide, which is rarely what a label animation should be. The input
does not accept timelineDefaults, which create rejects. Reading needs the ability to read interactions, and writing needs the
ability to modify them, on the main branch, in the canvas workflow and in Design mode. Several fields (value,
properties, pluginConfig and triggerMetadata) are typed loosely, so the schema reference is the place to check a
field before relying on it.
Extension utilities
Several small methods describe the environment of the extension.
const site = await webflow.getSiteInfo();
console.log("Site:", site.siteName, site.siteId);
console.log("Password protected:", site.isPasswordProtected);
const production = site.domains.filter((domain) => domain.stage === "production");
console.log("Production domains:", production.map((domain) => domain.url));
Source: Get site information
getSiteInfo returns the site and workspace IDs and slug, the password-protection and private-staging flags and the list of
domains, each with its URL, the date of its last publish and whether it is the default. Other utilities close the extension,
return the current breakpoint and the site’s media queries, the current pseudo state and the user’s Designer
capabilities, and issue a user ID token for backends (see Hybrid Apps).
Events and notifications
webflow.subscribe(event, callback) registers a listener and returns an unsubscribe function. The reference documents
events for the user selecting an element, changing breakpoint, changing page, changing CMS page, changing Designer mode and
changing the pseudo state.
const unsubscribe = webflow.subscribe("selectedelement", (element) => {
if (element) {
renderSelection(element.type);
} else {
renderSelection(null);
}
});
// When the panel closes or a screen is torn down
window.addEventListener("pagehide", () => unsubscribe());
Source: User selects element
The callback receives the element or null when nothing is selected. Keeping the returned function and calling it when a
screen is torn down avoids stacked listeners, the usual cause of a handler running several times after the panel is reopened.
notify
webflow.notify({ type, message }) shows a toast in the Designer. The type is Error, Info or Success, and the call
resolves to nothing once the toast is shown.
await webflow.notify({ type: "Success", message: "Label card added to bed C-14" });
await webflow.notify({ type: "Info", message: "Select a section to add the label to" });
await webflow.notify({ type: "Error", message: "The label could not be created. Try again." });
Source: Send notification to user
Notifications are the extension’s voice inside the Designer: use them for the outcome of an action the user started, in plain words and with a next step, and keep the panel itself for forms and state.
Sizing
webflow.setExtensionSize(size) resizes the panel. The size is default (240 by 360 pixels), comfortable (320 by 460),
large (800 by 600) or an object with a width and a height between 240 by 360 and 1200 by 800.
async function showPreview() {
await webflow.setExtensionSize("large");
}
async function hidePreview() {
await webflow.setExtensionSize("comfortable");
}
await webflow.setExtensionSize({ width: 480, height: 640 });
Source: Resize the extension
App modes and canForAppMode
The Designer has five public modes: design, build, preview, edit and comment. What an extension may do depends on
the mode, and on the locale, branch and workflow too. Instead of attempting a change and handling the rejection, the
extension asks what is allowed with webflow.canForAppMode(), which takes a list of ability names from webflow.appModes
and returns an object of booleans.
| Ability | Use |
|---|---|
|
Edit the canvas: requires the primary locale, the main branch, the canvas workflow and Design mode |
|
Edit content, such as text, in the canvas workflow |
|
Read the canvas |
|
Create and edit classes |
|
Create components and edit them |
|
Create pages, read and write their settings |
|
Read and write variables |
|
Read and write interactions |
|
Read and write assets |
|
Edit the properties of an image element |
Source: Designer API: App Modes
webflow.getCurrentMode() returns the current mode name (or null when it does not map to a public mode),
webflow.isMode("design") answers for a single mode, and webflow.subscribe("currentappmode", callback) fires whenever the
mode, the branch or the locale changes, with an event that carries the mode and the abilities.
const abilities = await webflow.canForAppMode([
webflow.appModes.canDesign,
webflow.appModes.canEdit,
]);
if (abilities.canDesign) {
const target = await webflow.getSelectedElement();
await target?.append(webflow.elementPresets.DOM);
}
const stop = webflow.subscribe("currentappmode", (event) => {
if (event.appModes.canDesign) {
enableDesignTools();
} else {
disableDesignTools();
}
});
Source: Designer API: App Modes
The subscription is what makes expandedAppModes worthwhile: with the flag on, the panel stays open as the user moves
between modes, and the extension disables the buttons that the new mode does not permit instead of closing and reopening.
Error handling and the error tags
A Designer API call that cannot complete rejects with an error whose message is for the developer and whose cause.tag is a
stable identifier the code can switch on.
| Tag | Typical meaning |
|---|---|
|
The user lacks the required role, locale or branch; see app modes |
|
The Designer is in the wrong mode for the operation |
|
An element, style, asset or page the code references no longer exists, for example after the user removed it |
|
The element cannot be placed there, such as a section inside a text block |
|
The operation does not apply to that kind of element |
|
A style, its name, a property or a variant is invalid |
|
The name is already used |
|
The variable or its value is invalid |
|
An asset or another resource could not be created |
|
A page could not be created |
|
The arguments are not accepted, for example |
|
Webflow failed internally |
Source: Designer API: Error Handling
Wrap Designer calls in try and catch, switch on error.cause.tag and turn the tag into a helpful message with notify,
with a default branch for the tags the code does not know yet.
export async function guarded<T>(label: string, action: () => Promise<T>): Promise<T | undefined> {
try {
return await action();
} catch (error) {
const err = error as { message?: string; cause?: { tag?: string } };
console.error(`${label}: ${err.cause?.tag ?? "unknown"} ${err.message ?? ""}`);
switch (err.cause?.tag) {
case "ResourceMissing":
await webflow.notify({ type: "Error", message: "That element is gone. Select another one." });
break;
case "InvalidElementPlacement":
await webflow.notify({ type: "Error", message: "The label cannot go there. Select a section or a div." });
break;
case "Forbidden":
case "ModeForbidden":
await webflow.notify({ type: "Error", message: "Switch to Design mode on the main branch to do this." });
break;
case "DuplicateValue":
await webflow.notify({ type: "Info", message: "That name already exists, nothing was changed." });
break;
default:
await webflow.notify({ type: "Error", message: `${label} failed. Try again.` });
}
return undefined;
}
}
Source: Designer API: Error Handling
The Plant Label Kit extension
The pieces now combine into the Plant Label Kit. Its job: the garden’s designer selects a section in the Designer, fills a short form (common name, botanical name, bed code) and presses a button; the extension makes sure the brand variables, the classes and the dark mode exist, builds the label card inside the selection and reports what happened. A second screen, opened from the Connections entry of an image element, writes alt text in three locales.
Files
The project keeps the template layout.
plant-label-kit/
├── public/
│ ├── index.html
│ └── styles.css
├── src/
│ ├── index.ts # wiring of the form and of the events
│ ├── brand.ts # variables and modes
│ ├── classes.ts # label-card classes
│ ├── label.ts # the element builder
│ ├── alt-text.ts # asset alt text per locale
│ └── guard.ts # error handling
├── tsconfig.json
├── package.json
└── webflow.json
The panel
The HTML is plain; the only requirement is a script that loads the built bundle.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plant Label Kit</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main>
<h1>Plant Label Kit</h1>
<form id="label-form">
<label>Common name <input name="common" required value="Lavender" /></label>
<label>Botanical name <input name="botanical" required value="Lavandula angustifolia" /></label>
<label>Bed code <input name="bed" required pattern="[A-Z]-[0-9]{1,2}" value="C-14" /></label>
<button type="submit" id="add-label">Add label card</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
<p id="mode"></p>
</main>
<script type="module" src="index.js"></script>
</body>
</html>
Brand variables and the dark mode
brand.ts creates the collection contents once and tolerates a second run, by looking each variable up before creating it.
export async function ensureBrand(): Promise<void> {
const collection = await webflow.getDefaultVariableCollection();
if (!collection) throw new Error("No default variable collection");
let leaf = await collection.getVariableByName("fernbrook-leaf");
if (!leaf) leaf = await collection.createColorVariable("fernbrook-leaf", "#2f6b3a");
let paper = await collection.getVariableByName("fernbrook-paper");
if (!paper) paper = await collection.createColorVariable("fernbrook-paper", "#faf7ef");
let dark = await collection.getVariableModeByName("Dark Mode");
if (!dark) dark = await collection.createVariableMode("Dark Mode");
if (leaf.type === "Color") await leaf.set("#8bc79a", { mode: dark });
if (paper.type === "Color") await paper.set("#1d2a20", { mode: dark });
}
Source: Create variable mode
Classes
classes.ts creates the base class and the combo class once, with a mobile override.
export async function ensureClasses(): Promise<void> {
let base = await webflow.getStyleByName("label-card");
if (!base) {
base = await webflow.createStyle("label-card");
await base.setProperties({
"display": "flex",
"flex-direction": "column",
"row-gap": "8px",
"padding-top": "16px",
"padding-right": "16px",
"padding-bottom": "16px",
"padding-left": "16px",
"border-top-width": "2px",
"border-right-width": "2px",
"border-bottom-width": "2px",
"border-left-width": "2px",
"border-top-style": "solid",
"border-right-style": "solid",
"border-bottom-style": "solid",
"border-left-style": "solid",
});
await base.setProperties({ "padding-top": "8px", "padding-bottom": "8px" }, { breakpoint: "small" });
}
const wide = await webflow.getStyleByName(["label-card", "is-wide"]);
if (!wide) {
const combo = await webflow.createStyle("is-wide", { parent: base });
await combo.setProperties({ "padding-left": "32px", "padding-right": "32px" });
}
}
Source: Create style
The label
label.ts builds the structure with the element builder, adds the class to the root through the builder’s setStyles, and
inserts it into the selected element in one operation.
export type Plant = { common: string; botanical: string; bed: string };
export async function insertLabel(plant: Plant): Promise<void> {
const parent = await webflow.getSelectedElement();
if (!parent?.children) {
throw Object.assign(new Error("Select a section or a div first"), {
cause: { tag: "InvalidTargetElement" },
});
}
const base = await webflow.getStyleByName("label-card");
const card = webflow.elementBuilder(webflow.elementPresets.DOM);
card.setTag("article");
card.setAttribute("data-bed", plant.bed);
if (base) card.setStyles([base]);
const title = card.append(webflow.elementPresets.DOM);
title.setTag("h3");
title.setTextContent(plant.common);
const latin = card.append(webflow.elementPresets.DOM);
latin.setTag("p");
latin.setTextContent(plant.botanical);
const bed = card.append(webflow.elementPresets.DOM);
bed.setTag("p");
bed.setTextContent(`Bed ${plant.bed}`);
await parent.append(card);
}
Source: Bulk Add Elements
The result on the canvas is an article element carrying the label-card class, with an h3 and two paragraphs inside and
the data-bed attribute set on the root.
Alt text per locale
alt-text.ts serves the second screen, the one opened from the image element’s Connections entry.
const LOCALE_IDS: Record<"es" | "de", string> = {
es: "REPLACE_WITH_SPANISH_LOCALE_ID",
de: "REPLACE_WITH_GERMAN_LOCALE_ID",
};
export type AltTexts = { en: string; es: string; de: string };
export async function writeAltTexts(texts: AltTexts): Promise<void> {
const element = await webflow.getSelectedElement();
if (element?.type !== "Image") {
throw Object.assign(new Error("Select an image"), { cause: { tag: "InvalidTargetElement" } });
}
const asset = await element.getAsset();
if (!asset) {
throw Object.assign(new Error("The image has no asset"), { cause: { tag: "ResourceMissing" } });
}
await asset.setAltText(texts.en);
await asset.setAltText(texts.es, LOCALE_IDS.es);
await asset.setAltText(texts.de, LOCALE_IDS.de);
}
Source: Set Alt Text
The locale IDs come from the site’s localization settings; the two placeholders are replaced with the IDs of the Spanish and the German locale of the Fernbrook site. How locales are configured is in Localization.
The guard
guard.ts holds the guarded function shown in the error-handling section; every button handler goes through it so that a
Designer rejection becomes a notification.
Wiring
index.ts ties the form, the mode check and the selection events together.
import { ensureBrand } from "./brand.js";
import { ensureClasses } from "./classes.js";
import { insertLabel } from "./label.js";
import { guarded } from "./guard.js";
const form = document.getElementById("label-form") as HTMLFormElement;
const button = document.getElementById("add-label") as HTMLButtonElement;
const status = document.getElementById("status") as HTMLElement;
const modeLabel = document.getElementById("mode") as HTMLElement;
async function refreshAbilities(): Promise<void> {
const abilities = await webflow.canForAppMode([webflow.appModes.canDesign]);
button.disabled = !abilities.canDesign;
modeLabel.textContent = abilities.canDesign
? "Design mode: you can add labels."
: "Switch to Design mode on the main branch to add labels.";
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
const done = await guarded("Add label", async () => {
await ensureBrand();
await ensureClasses();
await insertLabel({
common: String(data.get("common")),
botanical: String(data.get("botanical")),
bed: String(data.get("bed")),
});
return true;
});
if (done) {
status.textContent = "Label card added.";
await webflow.notify({ type: "Success", message: "Label card added" });
}
});
await webflow.setExtensionSize("comfortable");
await refreshAbilities();
webflow.subscribe("currentappmode", () => {
void refreshAbilities();
});
webflow.subscribe("selectedelement", (element) => {
status.textContent = element ? `Selected: ${element.type}` : "Nothing selected.";
});
Source: Designer API: App Modes
Run it with npm run dev, launch the development app from the Apps panel, select a section on the canvas and press the button.
The first run creates the variables, the mode and the classes; the second run only adds another card.
Testing and debugging
The extension is a web page in an iframe, so the browser’s developer tools apply: open them in the Designer window and pick the iframe’s context in the console. A few practices keep the loop short.
-
Log
error.cause.tagas well as the message; the tag is what the code can act on. -
Use a throwaway branch or a copy of the Fernbrook site for experiments with variables and classes: changes appear live, sync to collaborators and share the undo stack with the user.
-
Test the panel in each mode and each locale, since the abilities differ; the
currentappmodeevent is the quickest way to see the values change. -
Keep pure logic (building the label data, validating bed codes) in modules that do not touch
webflow, so that unit tests can run in Node; the guidance for tests in a CI pipeline is in Testing, CI and DevOps.
Pitfalls
-
Forgetting
awaiton a Designer call: the code appears to work and the canvas is unchanged. -
Calling a method on an element union without narrowing its type, which the compiler rejects and plain JavaScript turns into a runtime error.
-
Creating a class or variable without checking whether it exists: a second click raises
DuplicateValueor creates look-alikes. -
Assuming Design mode and the primary locale: the same call in the German locale or on a branch is
Forbidden. -
Writing page titles without noticing the current locale, so that the Spanish page receives the English title.
-
Using the deprecated presets (
Section,DivBlock,HFlex,VFlex,Grid) in new code instead of theDOMpreset. -
Leaving
timelineDefaultsin an interaction input, or targeting a class slug that matches several classes. -
Uploading assets over the size limits (4 MB for images, 10 MB for documents) and getting
ResourceCreationFailed. -
Editing
webflow.jsonand expecting the running panel to pick it up without a restart. -
Keeping subscriptions after a screen is gone, so that handlers run several times.
-
Putting secrets in the extension: the code and the bundle are visible to every user of the Designer, so tokens and client secrets belong on a backend, as covered in Hybrid Apps.
Summary
-
A Designer Extension is a web page loaded in an iframe in the Designer; the injected
webflowglobal is the Designer API, and the extension acts as the user, not through a token. -
webflow extension init <name> <template>scaffolds,webflow extension listshows the templates,webflow extension serve(port 1337 by default,--portto change) servespublic, and the development app is launched from the Apps panel after installing it on a test site. -
webflow.jsonholdsname,apiVersion,size,publicDir,appIntents,appConnectionsandfeatureFlags; app intents add the app to element settings and app connections link an element to it. -
The API covers elements and presets, the element builder, styles with combo classes, variables with modes, components, pages with SEO, assets with per-locale alt text, GSAP interactions, events,
notifyandsetExtensionSize. -
Ask before acting with
canForAppMode, switch onerror.cause.tagwhen a call fails and use notifications to tell the user what happened. -
The Plant Label Kit shows the pieces working together; its backend half is added in Hybrid Apps, and its distribution in Publishing Apps and the Marketplace.