Custom Attributes and JavaScript

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 covers the contract between what you configure in the Designer and the JavaScript you write yourself, as it was on 2026-10-28, the date on which its facts were checked against the official Webflow documentation. Webflow is a hosted service without version numbers, so the page is dated rather than versioned; Webflow updates announces changes. Custom attributes on elements are available on every plan. Custom code needs a paid plan (the official pages describe the plan requirement in terms of Workspace and Site plans, and this site names plans by tier only; see Plans, Workspaces, Seats and Roles). The examples use a Developer workspace site with the Fernbrook Gardens data, a fictitious botanical garden.

The page has one practical aim: to let a designer mark elements in the Designer and let a developer write a small, robust script that finds those elements and gives them behaviour, without fighting Webflow’s generated markup or its own runtime.

Custom attributes: the Designer side of the hook

Every element has a Settings panel. Besides the element-specific fields (for example href on a link or alt on an image), the panel has a Custom attributes section where you add name and value pairs. Webflow writes each pair as an HTML attribute on the published element. Adding the pair data-month with the value june to a Div Block produces markup like this.

<div data-month="june" class="plant-card w-dyn-item">
  ...
</div>

Three kinds of attribute are worth separating, because they have different owners.

Kind Examples Who reads it

Standard HTML attributes

href, alt, aria-label, role, tabindex, loading

The browser and assistive technology

Webflow’s own attributes

data-w-id, data-wf-page, data-wf-site, data-w-tab, data-animation, data-duration

webflow.js; do not edit or rely on them

Your attributes

data-month, data-filter, data-fb-role

Your script and your CSS

Webflow’s own data-* attributes drive the built-in widgets and Classic Interactions (see Classic Interactions). They can change between releases, so a script should never query them. Your attributes are the stable contract between the Designer and your code.

Why data attributes are better hooks than classes

A class in Webflow is a styling concept. Renaming a class in the Designer renames it everywhere, a class-naming system may rename dozens of them (see Class Naming Systems), and a designer who deletes an apparently unused class silently breaks a script that selects on it. A data attribute carries no style and says plainly that code depends on it.

  • Use classes for presentation and data-* attributes for behaviour.

  • Prefix the names so they are recognisable as yours, for example data-fb-filter and data-fb-month for Fernbrook Gardens.

  • Document the contract: write each attribute and its allowed values next to the component in the style guide, because the Designer shows no warning when someone removes one.

  • Values are always strings. the value of data-count set to 3 arrives in JavaScript as the string 3.

  • CSS can select on them too, which gives you a state without a combo class: [data-fb-state="active"] styles the active button when the script sets the attribute. The underlying standard is described in MDN’s Using data attributes and in HTML Structure.

Attribute values from the CMS

On a Collection template page or inside a Collection list, an element’s custom attribute value can come from the item instead of being typed once. For the Fernbrook plant cards, the bloom month stored in the Plants Collection becomes data-fb-month on each card, so the markup carries the data the script needs and the script never has to parse visible text. If a field cannot be bound to an attribute directly, a Code Embed inside the Collection item can print the field with the field insertion control, which is covered in Conditional Visibility and Dynamic Embeds. Field values must be clean: a Plain text field with the value June followed by a trailing space will not match june, so normalise on the JavaScript side as the example below does, or constrain the field with an Option field (see Collections and Field Types).

Attribute names and values are not validated for meaning. Webflow stores whatever you type, so a name that contains spaces or uppercase letters is the usual cause of a hook that "does nothing". Use lowercase letters, digits and hyphens.

What runs on a published site

A published Webflow page is static HTML plus stylesheets plus a small amount of JavaScript. The page you export or inspect loads a shared runtime, webflow.js, which is built on jQuery and provides the behaviour of the built-in components: the Navbar menu, Tabs, Slider, Lightbox, Dropdown, form handling and Classic Interactions. The generated markup and the files are described in HTML and CSS in Webflow, and the Classic engine is described in Classic Interactions.

Piece What it means for your code

webflow.js

Loaded by every published page; initialises widgets after the DOM is parsed. Treat its internals as private

jQuery

Bundled inside the runtime. Webflow documents jQuery as available for custom code on a published page, but it is an implementation detail of the runtime, so new code should not depend on it

Your head code

Runs before the body exists. Anything that touches elements must wait for the DOM

Your footer code

Runs after the body is parsed, after the runtime script is requested; the safest default for DOM scripts

Code Embed

Runs where the element sits; keep it small and put shared logic in the footer

The head and footer boxes, their limits and where each box is injected are covered in Custom Code in the Head, Footer and Embeds, and the official page is Custom code in head and body tags. Custom code does not run on the Designer canvas; check it in Preview mode, on the staging webflow.io domain or on the published site.

Webflow minifies its own JavaScript by default (Minified JavaScript by default). That applies to the runtime, not to your code: minify scripts you write yourself, or keep them short.

Plain JavaScript or jQuery

Because the runtime already contains jQuery, many tutorials write $(...) in a Code Embed. Plain JavaScript is the better default for new code.

  • Modern browsers implement querySelectorAll, classList, dataset, event delegation and fetch natively, so the features that made jQuery necessary are standard.

  • The bundled jQuery is not a versioned dependency you control, and Webflow does not promise to keep it exposed in future runtimes.

  • A script that uses only standard APIs keeps working if you export the code, move to another host or load the script from a CDN.

If you do use jQuery, the site has a tutorial in Getting Started with jQuery, with events and DOM manipulation. The vanilla example in this page can be compared with that approach.

Waiting until the page is ready

Custom scripts fail most often by running too early. There are three independent notions of "ready", and they must not be confused.

Mechanism What it waits for Use it for

DOMContentLoaded

The HTML is parsed and the DOM exists

Selecting elements and attaching listeners. This is the right tool for the filter below

load

Images, styles and sub-frames have finished

Measuring layout that depends on image sizes

wf.ready()

The Webflow browser API has loaded

Calling browser API methods such as consent, variations, custom goals and visitor attributes

wf.ready() is for the browser API

wf.ready() belongs to the Webflow browser API (developers.webflow.com/browser). That API lets a site’s code work with Webflow Analyze and Optimize in the visitor’s browser: manage consent, track experiment variations, set custom visitor attributes and send custom goals. The documentation states that the API is available on sites with Analyze or Optimize enabled and tracking turned on, and that it is exposed through the global wf object. The documented signature is wf.ready(callback), and the documentation instructs you to put every browser API call inside the callback.

wf.ready(function () {
  // Safe to call browser API methods here
  const consent = wf.getUserTrackingChoice();
  console.log(consent);
});

It is important to read what wf.ready() promises: that the browser API is loaded. It is not documented as a general "the page and its widgets are ready" hook, and the global wf object does not exist on a site where Analyze and Optimize are not enabled. A script that calls wf.ready() unconditionally would throw on such a site, so guard it.

function onBrowserApi(callback) {
  if (window.wf && typeof window.wf.ready === 'function') {
    window.wf.ready(callback);
  }
}

onBrowserApi(function () {
  // The "user" scope lasts for the visitor's session
  window.wf.setAttributes('user', { visitorType: 'member' });
});

Note the name clash. The custom attributes of the browser API are visitor properties sent to Optimize (the documentation describes them as available on Enterprise sites, with a pageview scope and a user scope). They are unrelated to the HTML custom attributes of the Settings panel that this page uses as hooks. The consent and tracking side of the API is part of Analyze and Optimize.

Waiting for the DOM

For DOM work, use the standard event, with a check for the case where the script runs after the event has already fired.

function onDomReady(callback) {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', callback, { once: true });
  } else {
    callback();
  }
}

Placed in the footer box, the script usually runs after the body is parsed, but the helper costs nothing and keeps the script correct if someone moves it to the head or loads it from an external file with defer.

The unofficial Webflow object

Searching the forums and community answers, you will find snippets that call methods on a global Webflow object. None of these calls is documented in the official Help Center or at developers.webflow.com as of the check date, so they are unofficial: they are described only in forum threads and blog posts, they may change or stop working in any runtime release, and Webflow offers no support for them. They belong to the Classic runtime (webflow.js) and do not apply to Interactions with GSAP (see Interactions with GSAP).

Call (unofficial) What forum answers use it for

Webflow.push(function () { ... })

Queue a function to run when the runtime is ready, so the code can sit in the head

Webflow.destroy()

Tear down the runtime’s bindings before re-initialising

Webflow.ready()

Run initialisation again after destroy

Webflow.require('ix2')

Obtain the Classic interactions module so that init() can be called on it after the DOM changes

The typical forum pattern is to re-run Classic Interactions after injecting new elements.

// Unofficial: forum-documented, not in the official docs, may change.
// Applies to Classic Interactions only, not to Interactions with GSAP.
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
  if (window.Webflow.require) {
    const ix2 = window.Webflow.require('ix2');
    if (ix2 && typeof ix2.init === 'function') {
      ix2.init();
    }
  }
});

Source: Webflow updates: minified JavaScript by default (official context for the runtime; the calls above are not documented there)

Guidelines for these calls:

  • Prefer designs that avoid them. If the page does not need to re-initialise a Classic interaction, do not.

  • Feature-detect everything, as in the example, so a runtime change turns the snippet into a no-op rather than an exception.

  • Do not combine with GSAP interactions. Interactions with GSAP are built on a different engine, and re-initialising the Classic one has no effect on them.

  • Keep them in one named function and one place in the code base, with a comment that says why it exists, so it is easy to delete.

  • Retest after every Webflow runtime change. The forum answers date from different eras of the runtime.

Example: a bloom-month filter on a Collection list

The Fernbrook Gardens home page has a Collection list of plants (the structure is explained in Collection Lists). The editors want visitors to narrow the cards to the plants that bloom in a chosen month. A Collection list’s own filter is part of the list’s query and is fixed when the site is published: a visitor cannot change a native filter at runtime. Interactive filtering therefore needs custom code (or a third-party attribute-based library, covered neutrally in Third-Party Libraries and Scripts). The example below is the custom-code route, built on data attributes.

Designer setup

  1. Build the Collection list of Plants. Give the Collection list wrapper the custom attribute data-fb-filter-list with an empty value.

  2. On the Collection item (the card), add the custom attribute data-fb-month, with its value bound to the bloom month field of the item (for example june). Use an Option field in the Collection so values are consistent.

  3. Above the list, add a Div Block with the attribute data-fb-filter-controls. Inside it add Button elements (not Div Blocks), each with data-fb-filter-value: one with the value all and one per month.

  4. Give the buttons a class such as filter-button and add a combo class is-active for the selected look. The script toggles aria-pressed, and the CSS below styles the selected state from that attribute, so the combo class is optional.

  5. Publish, because custom code does not run in the Designer.

The resulting markup is like the following.

<div data-fb-filter-controls class="filter-bar">
  <button type="button" class="filter-button" data-fb-filter-value="all">All</button>
  <button type="button" class="filter-button" data-fb-filter-value="june">June</button>
  <button type="button" class="filter-button" data-fb-filter-value="july">July</button>
</div>

<div data-fb-filter-list class="w-dyn-list">
  <div role="list" class="w-dyn-items">
    <div role="listitem" data-fb-month="june" class="w-dyn-item">...</div>
    <div role="listitem" data-fb-month="july" class="w-dyn-item">...</div>
  </div>
</div>

The script

Place it in the footer box of the page (or in a Code Embed on the page, below the list). It uses only standard DOM APIs and the data attributes above.

(function () {
  'use strict';

  var HIDDEN_ATTR = 'hidden';

  function init() {
    var controls = document.querySelector('[data-fb-filter-controls]');
    var list = document.querySelector('[data-fb-filter-list]');
    if (!controls || !list) {
      return; // markup not on this page
    }

    function normalise(value) {
      return String(value || '').trim().toLowerCase();
    }

    function apply(value) {
      var wanted = normalise(value);
      var items = list.querySelectorAll('[data-fb-month]');
      var shown = 0;
      items.forEach(function (item) {
        var matches = wanted === 'all' || normalise(item.dataset.fbMonth) === wanted;
        item.toggleAttribute(HIDDEN_ATTR, !matches);
        if (matches) {
          shown += 1;
        }
      });
      controls.querySelectorAll('[data-fb-filter-value]').forEach(function (button) {
        var pressed = normalise(button.dataset.fbFilterValue) === wanted;
        button.setAttribute('aria-pressed', String(pressed));
      });
      var status = document.getElementById('fb-filter-status');
      if (status) {
        status.textContent = shown + ' plants shown';
      }
    }

    // One delegated listener instead of one per button
    controls.addEventListener('click', function (event) {
      var button = event.target.closest('[data-fb-filter-value]');
      if (button) {
        apply(button.dataset.fbFilterValue);
      }
    });

    apply('all');
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();

How the script works:

  • dataset maps names. The attribute data-fb-month is read as item.dataset.fbMonth: the data- prefix is dropped and each hyphen followed by a letter becomes a capital letter.

  • hidden instead of display: none inline. The hidden attribute removes the card from rendering and from the accessibility tree without writing styles that the Designer cannot see. Webflow’s generated CSS can override it if a class sets display explicitly, so add the rule below to the site’s style code (or to the page head).

  • Event delegation. One listener on the controls container handles every button, including buttons added later. The same idea is explained for jQuery in Events.

  • Normalisation. trim() and toLowerCase() tolerate editor typos in the CMS.

  • The page works without the script. Without JavaScript all cards are visible, so the filter is an enhancement, not a requirement.

/* Make sure the hidden attribute wins over class-based display values */
[data-fb-month][hidden] {
  display: none !important;
}

/* Selected button state, driven by the attribute the script sets */
.filter-button[aria-pressed="true"] {
  background-color: #2f6b3c;
  color: #ffffff;
}

Accessibility of the filter

A filter is a set of controls, and it must be usable without a mouse and understandable without sight.

  • Use button elements. A Webflow Button element (or a Link Block turned into a button with role) is focusable and activated with Enter and Space. A Div Block with a click listener is not. The Settings panel lets you pick the element type; see Accessibility.

  • Expose the state with aria-pressed. Each filter button is a toggle button: aria-pressed=true marks the selected one, and screen readers announce it as pressed. The script updates it on every change.

  • Announce the result. Add a text element with the id fb-filter-status and the attributes role=status and aria-live=polite in the Designer. The script writes the count into it, so a screen reader user hears "3 plants shown" after activating a button.

  • Do not rely on colour alone. The pressed state in the CSS above changes background and text colour; add an icon, border or weight change so users with colour-vision differences can see it too.

  • Keep focus visible. Do not remove the focus outline from the buttons in the Style panel.

  • Group the controls. Give the filter bar role=group and an aria-label such as Filter plants by bloom month.

Running again after pagination and dynamic changes

Webflow pagination of a Collection list is rendered with links that load another page of the same template; each page is a new document, so the script runs again on it and the filter applies only to the items present on that page. Two consequences matter.

  • The filter does not see items on other pages. A visitor filtering for June on page 1 will not find June plants that sit on page 2. For a small catalogue, raise the list’s item limit so that all plants are on one page (within the list limits described in Collection Lists). For a large one, filter on the server side with a Collection list filter on a dedicated page for each month, or load data through the Content Delivery API (see Content Delivery API and Headless).

  • Items inserted after load need handling. If another script, or a third-party library, inserts or replaces cards after the page has loaded, the filter should re-apply itself. A MutationObserver on the list does this without polling.

// Inside init(), after apply('all') has been defined and called
var current = 'all';
var originalApply = apply;
apply = function (value) {
  current = value;
  originalApply(value);
};

var observer = new MutationObserver(function () {
  observer.disconnect();       // avoid reacting to our own changes
  originalApply(current);
  observer.observe(list, { childList: true, subtree: true });
});
observer.observe(list, { childList: true, subtree: true });

The observer watches child-list changes only, not attribute changes, so toggling hidden does not retrigger it; the disconnect and observe pair is a second guard against loops.

Pitfalls

  • Selecting on Webflow’s generated classes. Classes such as w-dyn-item are written by Webflow and are not a contract. Select on your own data-fb-* attributes.

  • Script in the head touching the DOM. The elements do not exist yet. Use the footer or the DOM-ready helper.

  • Assuming wf.ready() means the DOM is ready. It waits for the browser API only, and the wf object is absent without Analyze or Optimize.

  • Testing in the Designer. Custom code does not run on the canvas; the Preview mode and comment mode render on the *.canvas.webflow.com domain, so a CSP or an allow-list must permit it (see Custom Code in the Head, Footer and Embeds).

  • Case and whitespace in CMS values. Normalise both sides before comparing.

  • Duplicate ids. A Collection list repeats its items, so never put the same id on an element inside the list.

  • Re-initialising Classic Interactions on a GSAP site. The unofficial calls have no effect on Interactions with GSAP.

  • Replacing elements. Cloning or re-creating cards with innerHTML drops event listeners placed on them directly, which is another reason to delegate from a stable container.

  • Layout shift. Hiding cards changes page height; reserve space or place the filter above the fold of the list so that the content below does not jump unexpectedly (see Performance Optimization).

  • Security. Never insert CMS text with innerHTML; use textContent, as in the status line, because an editor-controlled value can contain markup. Do not place secrets in custom code, since everything in a published page is public.

Summary

  • Use custom attributes in the Settings panel and data-* names with a project prefix as the contract between design and code; use classes for styling only.

  • A published site runs webflow.js with bundled jQuery; prefer plain JavaScript for new code.

  • Wait for the DOM with DOMContentLoaded or a footer script; use wf.ready() only for browser API calls, and guard it because wf exists only with Analyze or Optimize.

  • The Webflow.push, Webflow.destroy, Webflow.ready and Webflow.require calls are unofficial, apply to Classic Interactions only, and should be isolated and feature-detected.

  • Native Collection list filters are fixed at publish time, so interactive filtering is a custom code job; build it with buttons, aria-pressed, a live status message and a no-JavaScript fallback.