Every lesson so far has lived entirely on the server: PHP building render arrays, YAML declaring routes, Twig turning data into HTML. But a real page also has to respond to clicks, without a full reload, and it has to keep working correctly even after Drupal swaps in new HTML via AJAX. That's where Drupal.behaviors comes in — and it's the one JavaScript pattern every Drupal developer needs to know before writing a single line of front-end code.
We'll study a real, working accordion widget from the official Examples project. Click a header, its panel opens; click another, the first one closes. Nothing exotic — but the way it's wired up teaches you the exact pattern Drupal core itself uses everywhere.
Why not just use $(document).ready()?
If you've written jQuery before, your instinct might be to reach for $(document).ready(function() { ... }) and attach your click handlers there. On a traditional, full-page-reload website that works fine. But Drupal pages are frequently updated after the initial load — a form validation error swaps in new markup via AJAX, a "Load more" button appends new content, a modal opens with server-rendered HTML inside it. None of that triggers a fresh document.ready event, because the page never reloaded. Any JavaScript that only runs once, on initial load, will silently fail to apply to that new content.
Drupal solves this with Drupal.behaviors: instead of running your setup code once, you register a function that Drupal calls every time new content appears — on first load, and again after every AJAX response.
What you'll learn in this lesson
- Why
Drupal.behaviorsexists and how it differs from a plain jQuery ready handler - What the
attach(context)method actually receives, and why scoping matters - How the
once()utility prevents the same click handler from being attached twice - How to read a real Drupal behavior file end to end, including the IIFE wrapper convention
The source file
Path: modules/js_example/js/accordion.js
/**
* @file
* Contains the accordion behaviors.
*/
((once, Drupal) => {
/**
* Hides and shows the accordion items.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the behavior to the accordion wrapper.
*/
Drupal.behaviors.javaScriptExampleAccordion = {
attach(context) {
once(
'javascript-example-accordion',
'.accordion-wrapper',
context,
).forEach((accordion) => {
const items = accordion.querySelectorAll('.accordion-item');
const headers = accordion.querySelectorAll('.accordion-item-header');
/**
* Toggles the visibility of accordion items.
*
* @param {Event} e
* The triggered click event.
*/
const toggleItem = (e) => {
/** @type {HTMLDivElement} */
const clickedItem = e.currentTarget.parentNode;
for (let i = 0; i < items.length; i++) {
items[i].classList.add('close');
items[i].classList.remove('open');
}
if (clickedItem.classList.contains('close')) {
clickedItem.classList.remove('close');
clickedItem.classList.add('open');
}
};
for (let i = 0; i < headers.length; i++) {
headers[i].addEventListener('click', toggleItem);
}
});
},
};
})(once, Drupal);
Under 50 lines, and every one of them is doing something deliberate. Let's take it apart piece by piece.
How it works, piece by piece
The IIFE wrapper
((once, Drupal) => { ... })(once, Drupal);
This is an Immediately Invoked Function Expression — a function that's defined and called in the same breath. The global once and Drupal objects are passed in as arguments and received as local parameters with the same names. Two things fall out of this: nothing declared inside the function leaks into the global scope, and anyone skimming the file immediately sees exactly what it depends on. This is the standard opening for essentially every JavaScript file in Drupal core and contrib — get used to seeing it.
Registering the behavior
Drupal.behaviors.javaScriptExampleAccordion = {
attach(context) { ... },
};
Drupal.behaviors is just a plain object acting as a registry. Add a property to it, and that property becomes a behavior — as long as its value is an object with at least an attach method. Drupal core calls every registered behavior's attach method at two distinct moments: once when the page first finishes loading (passing the entire document), and again every single time an AJAX response inserts new HTML anywhere on the page (passing only that new fragment). Your setup code isn't a one-shot deal — it's designed to be safely re-run.
The name javaScriptExampleAccordion is arbitrary, but it must be unique across every behavior on the entire site. The convention — used here — is a descriptive camelCase name prefixed with your module's name, precisely to avoid two modules accidentally registering a behavior with the same key and silently overwriting each other.
The attach(context) parameter
context is the one parameter that makes this whole system work. On first page load it's the full document. After an AJAX call, it's only the newly inserted DOM fragment — not the whole page. Scoping every query to context means your behavior only ever processes elements that are actually new, instead of re-scanning the entire page (and re-attaching duplicate listeners) every single time anything changes anywhere on the site.
The once() utility
once(
'javascript-example-accordion',
'.accordion-wrapper',
context,
).forEach((accordion) => { ... });
once() is a small, standalone Drupal utility (the @drupal/once package, exposed globally as once) that solves a subtler problem than context scoping alone can. Even with careful scoping, edge cases exist where attach could still run against the same element more than once. once() guards against that by tagging every matched element with a hidden data attribute the first time it processes it. Call once() again later against that same element, and it's simply excluded from the returned array — the forEach body never runs a second time for it.
The three arguments matter:
'javascript-example-accordion'— a unique ID string that namespaces this particular "once" tag, so it doesn't collide with some other behavior also callingonce()on the same elements.'.accordion-wrapper'— the CSS selector identifying which elements to process.context— passed straight through fromattach, soonce()only searches within the current scope.
once() returns an array of the elements that have not yet been processed, which is exactly why it can be chained directly with .forEach().
once(), an accordion that gets re-rendered by an unrelated AJAX request elsewhere on the page could end up with two, three, or a dozen click listeners stacked on the same header — each firing independently. You'd see the panel flicker or toggle multiple times per click, and it would be maddening to debug without knowing this pattern exists.Scoped DOM queries and the toggle logic
const items = accordion.querySelectorAll('.accordion-item');
const headers = accordion.querySelectorAll('.accordion-item-header');
querySelectorAll is called on the individual accordion element returned by once() — not on document. That keeps the query correctly scoped if more than one accordion widget exists on the same page; each one manages only its own items.
const toggleItem = (e) => {
const clickedItem = e.currentTarget.parentNode;
for (let i = 0; i < items.length; i++) {
items[i].classList.add('close');
items[i].classList.remove('open');
}
if (clickedItem.classList.contains('close')) {
clickedItem.classList.remove('close');
clickedItem.classList.add('open');
}
};
This is the actual accordion logic, and it's a neat little trick: on every click, every item is first forced closed. Then — and only then — it checks whether the clicked item was just closed by that same loop; if so, it's immediately reopened. The net effect: clicking an already-open panel closes it (it stays closed after the loop), and clicking a closed panel opens it while closing everything else. One loop, no separate "close the previously open one" bookkeeping required.
Attaching the listeners
for (let i = 0; i < headers.length; i++) {
headers[i].addEventListener('click', toggleItem);
}
Because once() already guaranteed this whole block runs at most once per accordion element, there's no risk of this loop stacking duplicate listeners on later AJAX-triggered calls to attach.
See it for yourself
Visit the live accordion on your own DDEV site and click around. Notice that whichever panel is open has a grey header and visible body text, while the others stay collapsed with blue headers — proof the click handlers, the toggle logic, and the CSS classes are all wired together correctly.
In the screenshot above, "Cicero" is the open panel — its header has turned grey and its paragraph text is visible — while "Lorem ipsum" and "Werther" remain collapsed with their blue headers showing. That's exactly the behavior the toggleItem function you just read produces: click any header, and it becomes the one open item while every other one closes.
Quick check: if this accordion widget appeared twice on the same page — say, in two different blocks — would clicking a header in the first accordion accidentally affect the second one? Look back at how
itemsandheadersare queried. (Answer: no — both are queried from the individualaccordionelement inside theforEach, not fromdocument, so each widget instance is fully isolated.)
Key takeaways
Drupal.behaviorsis the Drupal-idiomatic way to write JavaScript — use it instead of$(document).ready()or inline scripts so your code keeps working after AJAX updates.- The
attach(context)method runs on every AJAX response, not just on page load; always scope DOM queries tocontextinstead ofdocument. - Use
once('unique-id', selector, context)to guarantee your setup logic runs at most once per element, no matter how many timesattachfires. - Wrap your behavior file in an IIFE and pass
onceandDrupalin as explicit parameters — it keeps the global scope clean and documents your dependencies at a glance. - Query with
querySelectorAllon a specific ancestor element (notdocument) whenever multiple instances of a widget might exist on the same page. - Name every behavior with a module-prefixed camelCase key (e.g.
Drupal.behaviors.myModuleWidgetName) to avoid collisions with behaviors registered by other modules.
Coming up next
You've now seen the JavaScript itself — but where did that accordion.js file actually get loaded from, and how does Drupal know to send it to the browser in the first place? In the next lesson, we'll open js_example.libraries.yml and see exactly how Drupal declares, versions, and attaches JS and CSS assets — the missing piece that gets this file onto the page at all.