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 |
|
The browser and assistive technology |
Webflow’s own attributes |
|
|
Your attributes |
|
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-filteranddata-fb-monthfor 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-countset 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 |
|---|---|
|
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 andfetchnatively, 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 |
|---|---|---|
|
The HTML is parsed and the DOM exists |
Selecting elements and attaching listeners. This is the right tool for the filter below |
|
Images, styles and sub-frames have finished |
Measuring layout that depends on image sizes |
|
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);
});
Source: Webflow Developers: wf.ready()
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();
}
}
Source: MDN: DOMContentLoaded event
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 |
|---|---|
|
Queue a function to run when the runtime is ready, so the code can sit in the head |
|
Tear down the runtime’s bindings before re-initialising |
|
Run initialisation again after |
|
Obtain the Classic interactions module so that |
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
-
Build the Collection list of Plants. Give the Collection list wrapper the custom attribute
data-fb-filter-listwith an empty value. -
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 examplejune). Use an Option field in the Collection so values are consistent. -
Above the list, add a Div Block with the attribute
data-fb-filter-controls. Inside it add Button elements (not Div Blocks), each withdata-fb-filter-value: one with the valuealland one per month. -
Give the buttons a class such as
filter-buttonand add a combo classis-activefor the selected look. The script togglesaria-pressed, and the CSS below styles the selected state from that attribute, so the combo class is optional. -
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();
}
})();
Source: MDN: Using data attributes
How the script works:
-
datasetmaps names. The attributedata-fb-monthis read asitem.dataset.fbMonth: thedata-prefix is dropped and each hyphen followed by a letter becomes a capital letter. -
hiddeninstead ofdisplay: noneinline. Thehiddenattribute 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 setsdisplayexplicitly, 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()andtoLowerCase()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;
}
Source: MDN: aria-pressed
Accessibility of the filter
A filter is a set of controls, and it must be usable without a mouse and understandable without sight.
-
Use
buttonelements. A Webflow Button element (or a Link Block turned into a button withrole) 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=truemarks 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-statusand the attributesrole=statusandaria-live=politein 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=groupand anaria-labelsuch 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
MutationObserveron 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 });
Source: MDN: MutationObserver
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-itemare written by Webflow and are not a contract. Select on your owndata-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 thewfobject 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.comdomain, 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
idon 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
innerHTMLdrops 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; usetextContent, 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.jswith bundled jQuery; prefer plain JavaScript for new code. -
Wait for the DOM with
DOMContentLoadedor a footer script; usewf.ready()only for browser API calls, and guard it becausewfexists only with Analyze or Optimize. -
The
Webflow.push,Webflow.destroy,Webflow.readyandWebflow.requirecalls 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.
To go further, read Third-Party Libraries and Scripts, Interactions with GSAP, Collection Lists and Accessibility.