Skip to content

TutorialFor theme developersDrupal 11

Build your first theme 5: JavaScript behaviors with once()

In part 5 of the theme tutorial the card component learns to collapse. Along the way you meet the two conventions all Drupal JavaScript follows: behaviors, and the once() guard.

The collapsible-card script in js/first-theme.js, the file you created empty in part 3 and whose library wiring (core/drupal and core/once dependencies) already exists. Clicking a collapsible card’s heading toggles its body.

  • First theme as part 4 left it: teasers render through the card component.
  • CSS and JavaScript aggregation turned off while developing (see part 3’s troubleshooting).

First, the reason theme JavaScript is not a plain DOMContentLoaded listener. Drupal pages change after the initial load: Ajax responses insert new content, dialogs open, previews refresh, BigPipe streams late content into placeholders. A DOMContentLoaded listener runs once, before any of that exists, so JavaScript written that way silently skips everything added later.

The behavior system is core’s answer. Code registered on the Drupal.behaviors object gets an attach(context, settings) method, and core calls every behavior’s attach:

  • once when the page first loads, with context set to the whole document, and
  • again every time content is added, with context set to only the new fragment.

The same attach therefore runs many times, and that creates the next problem: an element present in the document and inside a later context would be processed twice, with double event listeners as the classic symptom. The once() function (from the core/once library, vendored by core as @drupal/once 1.0.1) exists for exactly this: once('id', selector, context) returns the matching elements that have not been marked with that ID yet, and marks them by setting a data-once attribute.

With those two conventions in hand, the script almost writes itself. Replace the empty js/first-theme.js with:

web/themes/custom/first_theme/js/first-theme.js
/**
* @file
* Collapsible card behavior for the First theme.
*/
((Drupal, once) => {
/**
* Lets visitors collapse and expand collapsible cards.
*
* Behaviors run on initial page load and again for content added by
* Ajax — "context" is only the new part of the page. once() marks
* elements as processed so the same card is never wired up twice.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.firstThemeCollapsibleCard = {
attach(context) {
once('collapsible-card', '.card--collapsible', context).forEach(
(card) => {
const heading = card.querySelector('.card__heading');
heading.addEventListener('click', () => {
card.classList.toggle('is-collapsed');
});
},
);
},
};
})(Drupal, once);

Reading it top to bottom:

  • The file is wrapped in an immediately invoked function so Drupal and once are explicit parameters instead of bare globals; both exist because the library from part 3 depends on core/drupal and core/once.
  • Drupal.behaviors.firstThemeCollapsibleCard registers the behavior under a name unique across the site; the theme-name prefix prevents collisions with modules.
  • attach(context) queries .card--collapsible within the context, never the whole document, so Ajax-added cards get wired up by their own attach call.
  • once('collapsible-card', ...) guarantees each card is wired exactly once, no matter how many times attach runs.
  • The click handler toggles is-collapsed, and part 4’s card.css already hides .card.is-collapsed .card__content. The behavior owns state, the stylesheet owns appearance.

The behavior targets .card--collapsible, the class the card template adds when its collapsible prop is true, and nothing passes that prop yet. Try it out by passing the prop from the teaser template’s embed (the opening line from part 4, plus one prop):

{% embed 'first_theme:card' with {
attributes: attributes,
heading_level: 3,
collapsible: true,
} only %}

Reload the front page and click a teaser heading: the body collapses, a second click expands it. Inspect a card element and you see the machinery: class="card card--collapsible" and data-once="collapsible-card" left by the once() call.

Revert the template to part 4’s version afterwards (teasers that collapse their own body link is questionable UI); the behavior stays, ready for wherever the card is embedded with collapsible: true, and part 6’s settings work continues from the committed template.

“once is not defined” or “Drupal is not defined” in the console

Section titled ““once is not defined” or “Drupal is not defined” in the console”

The script loaded without its dependencies. Both come from the dependencies: list of first_theme/global in first_theme.libraries.yml (part 3); check core/once and core/drupal are listed and rebuild caches.

First check the class: the behavior only matches .card--collapsible, so a card without collapsible: true is intentionally inert. Then check the file really loads (page source, or the Network tab); a stale aggregate or a cached library definition hides new JS, so turn off aggregation and run ddev drush cache:rebuild.

Some code attached the listener outside the once() guard, or two behaviors process the same element under different once IDs. Every listener your theme adds belongs inside a once(...) loop; the ID string is the deduplication key, so reuse the same ID for the same concern.

My script ran before the page finished loading

Section titled “My script ran before the page finished loading”

Behaviors already run at the right moment; do not add your own DOMContentLoaded wrapper inside attach, because attach may run long after that event for Ajax content, and the wrapper’s listener would never fire.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.