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.

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

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

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

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

name

Yes

The name of the app; descriptive and unique

apiVersion

No

The version of the Designer APIs the app uses; the documentation recommends "2"

size

No

The initial size of the extension window: default (240 by 360 pixels), comfortable (320 by 460) or large (800 by 600). Defaults to default and can be changed at runtime with webflow.setExtensionSize()

publicDir

No

The directory built and served; defaults to public

appIntents

No

The element types that can create connections to the app, each with a list of intents

appConnections

No

Unique identifiers of the connections between the app and canvas elements

featureFlags

No

Boolean switches for extra behavior; the supported key is expandedAppModes

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");
}

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.

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

To launch the development version inside the Designer:

  1. 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.

  2. 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.

  3. Open the test site in the Designer and press the E key to open the Apps panel.

  4. 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`);
}

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");
}

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" }));
}

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.

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" });

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);

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);

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
}

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);
}

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);
}

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());

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);

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));

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());

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." });

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 });

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

canDesign

Edit the canvas: requires the primary locale, the main branch, the canvas workflow and Design mode

canEdit

Edit content, such as text, in the canvas workflow

canAccessCanvas

Read the canvas

canCreateStyles, canModifyStyles

Create and edit classes

canCreateComponents, canModifyComponents

Create components and edit them

canCreatePage, canReadPageSettings, canManagePageSettings

Create pages, read and write their settings

canReadVariables, canModifyVariables

Read and write variables

canReadInteractions, canModifyInteractions

Read and write interactions

canAccessAssets, canManageAssets

Read and write assets

canModifyImageElement

Edit the properties of an image element

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();
  }
});

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

Forbidden

The user lacks the required role, locale or branch; see app modes

ModeForbidden

The Designer is in the wrong mode for the operation

ResourceMissing

An element, style, asset or page the code references no longer exists, for example after the user removed it

InvalidElementPlacement

The element cannot be placed there, such as a section inside a text block

InvalidTargetElement

The operation does not apply to that kind of element

InvalidStyle, InvalidStyleName, InvalidStyleProperty, InvalidStyleVariant

A style, its name, a property or a variant is invalid

DuplicateValue

The name is already used

VariableInvalid

The variable or its value is invalid

ResourceCreationFailed

An asset or another resource could not be created

PageCreateFailed

A page could not be created

InvalidRequest

The arguments are not accepted, for example replace with a preset

InternalError

Webflow failed internally

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;
  }
}

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 });
}

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);
}

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.";
});

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.tag as 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 currentappmode event 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 await on 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 DuplicateValue or 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 the DOM preset.

  • Leaving timelineDefaults in 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.json and 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 webflow global is the Designer API, and the extension acts as the user, not through a token.

  • webflow extension init <name> <template> scaffolds, webflow extension list shows the templates, webflow extension serve (port 1337 by default, --port to change) serves public, and the development app is launched from the Apps panel after installing it on a test site.

  • webflow.json holds name, apiVersion, size, publicDir, appIntents, appConnections and featureFlags; 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, notify and setExtensionSize.

  • Ask before acting with canForAppMode, switch on error.cause.tag when 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.