Skip to main content
Average reading time: 16 minutes, 57 seconds

User Rating: 5 / 5

Total Votes: 1
Please Rate

Web components are the native, framework-free way to ship interactive UI in Joomla 4, 5 and 6 — and Joomla core already runs on them. Open the browser inspector on any admin page and you will find <joomla-alert>, <joomla-tab>, <joomla-field-fancy-select>, <joomla-field-media> and <joomla-toolbar-button>. Core moved away from jQuery-era widgets to standard Custom Elements, and your extensions can do the same.

They fit Joomla's architecture unusually well:

  • PHP renders, the browser upgrades. A layout or module template outputs a custom tag with its content; JavaScript attaches behaviour. No build step, no virtual DOM, no hydration.
  • Lifecycle for free. connectedCallback fires whenever the element enters the DOM — on page load, after an AJAX-injected module, inside a modal. No more DOMContentLoaded plus re-init hacks.
  • Scoped by design. Each instance owns its state, so two copies of the same module on one page never collide.
  • Template-agnostic. The same element works in Cassiopeia, Atum, a custom template or a page builder.

In this tutorial you will build four things, each a complete, installable pattern:

  1. A custom element shipped by a site module, loaded through the Web Asset Manager.
  2. A module chrome that wraps any module in a collapsible web component.
  3. Reusable patterns: a self-loading data element backed by com_ajax, and a form-associated custom field.
  4. A Shadow DOM component that stays themeable from the Joomla template.

The code targets Joomla 5 and 6 (namespaced extensions, PHP 8.1+) and works on Joomla 4.4 with minor manifest changes. JavaScript is plain ES2022 modules — no bundler required.

Foundations: shipping a component the Joomla way

Every web component in Joomla should be a file in /media, registered in joomla.asset.json, and loaded through the Web Asset Manager (WAM). Never echo a <script> tag from a template: WAM handles ordering, dependencies, versioning and deduplication when the same module appears five times on one page.

File layout

mod_wcdemo/
├── mod_wcdemo.xml
├── services/provider.php
├── src/Dispatcher/Dispatcher.php
├── tmpl/default.php
├── language/en-GB/mod_wcdemo.ini
└── media/
    ├── joomla.asset.json
    ├── js/wc-countdown.js
    └── css/wc-countdown.css

The manifest copies media/ to /media/mod_wcdemo/:

<media folder="media" destination="mod_wcdemo">
    <filename>joomla.asset.json</filename>
    <folder>js</folder>
    <folder>css</folder>
</media>

Register the component as an ES module

media/joomla.asset.json:

{
  "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
  "name": "mod_wcdemo",
  "version": "1.0.0",
  "assets": [
    {
      "name": "mod_wcdemo.countdown",
      "type": "script",
      "uri": "mod_wcdemo/wc-countdown.js",
      "dependencies": ["core"],
      "attributes": { "type": "module" },
      "version": "auto"
    },
    {
      "name": "mod_wcdemo.countdown",
      "type": "style",
      "uri": "mod_wcdemo/wc-countdown.css",
      "version": "auto"
    }
  ]
}

Three details matter:

  • "type": "module" makes the browser treat the file as an ES module: deferred by default, strict mode, its own scope, import allowed.
  • "dependencies": ["core"] guarantees Joomla.getOptions() and Joomla.Text exist before your code runs.
  • The uri omits js/ and css/. WAM resolves mod_wcdemo/x.js to media/mod_wcdemo/js/x.js and also picks up a .min.js sibling when debug is off.

WAM auto-loads asset files for the active component and template only. A module must register its file explicitly:

$wa = $app->getDocument()->getWebAssetManager();
$wa->getRegistry()->addExtensionRegistryFile('mod_wcdemo');
$wa->useScript('mod_wcdemo.countdown')
   ->useStyle('mod_wcdemo.countdown');

Passing data from PHP to the element

Use the right channel for each kind of data:

Data

Channel

Read in JS

Per-instance config (date, limit, ID)

HTML attributes on the element

this.getAttribute() / this.dataset

Translated strings

Text::script('MOD_WCDEMO_DONE')

Joomla.Text._('MOD_WCDEMO_DONE')

Page-wide settings

$doc->addScriptOptions('mod_wcdemo', [...])

Joomla.getOptions('mod_wcdemo')

CSRF token

added by core

Joomla.getOptions('csrf.token')

Content

child markup (light DOM)

slots or this.querySelector()

Attributes are the default. They keep each instance independent, they are visible in the inspector, and they work with observedAttributes for live updates.

A safe registration helper

A custom element name can be defined only once per page; a second customElements.define() throws. Since a template override, a page builder or another extension may load your file twice, always guard it:

export function define(name, ctor) {
  if (!customElements.get(name)) {
    customElements.define(name, ctor);
  }
}

Name elements with a vendor prefix (ep-, jl-, acme-). The joomla- prefix belongs to core, and generic names like my-tabs will eventually collide.

Example 1: creating a custom element in a module

The first component is <jl-countdown>: an event countdown whose target date, labels and finished message come from module parameters. It follows the rule that matters most in Joomla: PHP renders a useful fallback, JavaScript enhances it. Search engines, users without JS and the brief moment before the module script runs all see the event date.

The module template

tmpl/default.php:

<?php
defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;

/** @var \Joomla\Registry\Registry $params */
$app = Factory::getApplication();
$wa  = $app->getDocument()->getWebAssetManager();
$wa->getRegistry()->addExtensionRegistryFile('mod_wcdemo');
$wa->useScript('mod_wcdemo.countdown')->useStyle('mod_wcdemo.countdown');

Text::script('MOD_WCDEMO_DAYS');
Text::script('MOD_WCDEMO_HOURS');
Text::script('MOD_WCDEMO_MINUTES');
Text::script('MOD_WCDEMO_SECONDS');

$target = (string) $params->get('target_date', '');
$done   = (string) $params->get('done_text', Text::_('MOD_WCDEMO_DONE'));

if ($target === '') {
    return;
}

$iso = HTMLHelper::_('date', $target, 'c', 'UTC');
// Module layouts run inside the Dispatcher, so there is no $this->escape().
$e = static fn (string $v): string => htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
?>
<jl-countdown target="<?php echo $e($iso); ?>"
              done-text="<?php echo $e($done); ?>">
    <time datetime="<?php echo $e($iso); ?>">
        <?php echo HTMLHelper::_('date', $target, Text::_('DATE_FORMAT_LC2')); ?>
    </time>
</jl-countdown>

If you prefer to keep templates free of asset logic, move the WAM and Text::script() calls into your Dispatcher::getLayoutData(). Either works; the template version is shown here because it survives template overrides intact.

The element

media/js/wc-countdown.js:

const t = (key) => Joomla.Text._(key);

class JlCountdown extends HTMLElement {
  static observedAttributes = ['target', 'done-text'];
  #timer = null;

  connectedCallback() {
    this.setAttribute('role', 'timer');
    this.#start();
  }

  disconnectedCallback() {
    clearInterval(this.#timer);
  }

  attributeChangedCallback() {
    if (this.isConnected) this.#start();
  }

  #start() {
    clearInterval(this.#timer);
    this.#tick();
    this.#timer = setInterval(() => this.#tick(), 1000);
  }

  #tick() {
    const target = Date.parse(this.getAttribute('target'));
    const diff = target - Date.now();

    if (Number.isNaN(target)) return;

    if (diff <= 0) {
      clearInterval(this.#timer);
      this.textContent = this.getAttribute('done-text') ?? '';
      this.dispatchEvent(new CustomEvent('jl-countdown:done', { bubbles: true }));
      return;
    }

    const parts = {
      MOD_WCDEMO_DAYS: Math.floor(diff / 864e5),
      MOD_WCDEMO_HOURS: Math.floor(diff / 36e5) % 24,
      MOD_WCDEMO_MINUTES: Math.floor(diff / 6e4) % 60,
      MOD_WCDEMO_SECONDS: Math.floor(diff / 1e3) % 60,
    };

    this.replaceChildren(
      ...Object.entries(parts).map(([key, value]) => {
        const cell = document.createElement('span');
        cell.className = 'jl-countdown__cell';
        cell.innerHTML = `<b>${value}</b><small></small>`;
        cell.querySelector('small').textContent = t(key);
        return cell;
      }),
    );
  }
}

if (!customElements.get('jl-countdown')) {
  customElements.define('jl-countdown', JlCountdown);
}

Notice what Joomla gives you for free here:

  • Translations come from the site language via Text::script(); the element never hardcodes English.
  • Cleanup happens in disconnectedCallback, so a module removed by a page builder or swapped by AJAX stops its timer.
  • Integration hooks use a bubbling CustomEvent. A template script can listen on document for jl-countdown:done without knowing anything about the module.

Avoid the flash of unstyled content

CSS can target elements that are not yet upgraded. Keep the fallback visible but reserve space so the layout does not jump:

jl-countdown { display: flex; gap: .75rem; min-height: 3.5rem; align-items: center; }
jl-countdown:not(:defined) time { opacity: .6; }
.jl-countdown__cell { display: grid; text-align: center; }
.jl-countdown__cell b { font-size: 1.75rem; font-variant-numeric: tabular-nums; }

Custom elements default to display: inline. Always set an explicit display for block-level components, or margins and widths will behave unexpectedly inside module positions.

Example 2: web components inside module chrome

Module chrome is the most underused place for web components in Joomla. A chrome wraps the output of any module — core, third-party or custom — so one chrome file gives every module on the site the same behaviour without touching a single module. Here we build <jl-panel>: an accessible collapsible wrapper that remembers its state per module.

How chrome works

A chrome is a layout at templates/<template>/html/layouts/chromes/<name>.php. Joomla passes it $displayData with three keys:

Key

Type

Holds

module

stdClass

id, title, showtitle, position, content (already rendered)

params

Registry

module params: header_tag, header_class, moduleclass_sfx, module_tag

attribs

array

attributes from <jdoc:include … style="…" />

Once the file exists, the chrome appears in every module's Advanced → Module Style list. You can also force it for a whole position in index.php:

<jdoc:include type="modules" name="sidebar-right" style="jlpanel" />

Use a child template (Joomla 4.1+) so core updates to Cassiopeia never overwrite your chrome.

The chrome file

templates/cassiopeia_jl/html/layouts/chromes/jlpanel.php:

<?php
defined('_JEXEC') or die;

use Joomla\CMS\Factory;

$module = $displayData['module'];
$params = $displayData['params'];

if ((string) $module->content === '') {
    return;
}

Factory::getApplication()->getDocument()
    ->getWebAssetManager()
    ->useScript('template.jl-panel')
    ->useStyle('template.jl-panel');

$e = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');

$headerTag   = $e($params->get('header_tag', 'h3'));
$headerClass = $e($params->get('header_class', ''));
$sfx         = (string) $params->get('moduleclass_sfx', '');
$collapsed   = str_contains($sfx, 'is-collapsed');
?>
<jl-panel id="jl-panel-<?php echo (int) $module->id; ?>"
          class="moduletable <?php echo $e($sfx); ?>"
          <?php echo $collapsed ? '' : 'open'; ?> persist>
    <?php if ($module->showtitle) : ?>
        <<?php echo $headerTag; ?> class="jl-panel__heading <?php echo $headerClass; ?>">
            <?php echo $e($module->title); ?>
        </<?php echo $headerTag; ?>>
    <?php endif; ?>
    <div class="jl-panel__body">
        <?php echo $module->content; ?>
    </div>
</jl-panel>

Administrators control the default state with Module Class is-collapsed — no new parameters, no plugin, no hack.

Register the asset in the child template

The active template's joomla.asset.json is loaded automatically, so the chrome only needs useScript():

{
  "name": "cassiopeia_jl",
  "version": "1.0.0",
  "assets": [
    { "name": "template.jl-panel", "type": "script", "uri": "jl-panel.js", "attributes": { "type": "module" }, "version": "auto" },
    { "name": "template.jl-panel", "type": "style",  "uri": "jl-panel.css", "version": "auto" }
  ]
}

Files live in media/templates/site/cassiopeia_jl/js/ and /css/.

The component

<jl-panel> works in the light DOM on purpose: module content must keep receiving template CSS, and third-party modules may run their own scripts against it.

const KEY = 'jl-panel:';

class JlPanel extends HTMLElement {
  connectedCallback() {
    if (this.button) return; // already upgraded (element was moved)

    const heading = this.querySelector(':scope > .jl-panel__heading');
    this.body = this.querySelector(':scope > .jl-panel__body');
    if (!heading || !this.body) return;

    this.body.id ||= `${this.id}-body`;
    this.button = document.createElement('button');
    this.button.type = 'button';
    this.button.className = 'jl-panel__toggle';
    this.button.setAttribute('aria-controls', this.body.id);
    this.button.append(...heading.childNodes);
    heading.append(this.button);

    if (this.hasAttribute('persist')) {
      const saved = this.#read();
      if (saved !== null) this.toggleAttribute('open', saved);
    }

    this.button.addEventListener('click', () => this.toggle());
    this.#sync();
  }

  static observedAttributes = ['open'];
  attributeChangedCallback() { if (this.button) this.#sync(); }

  toggle(force) {
    this.toggleAttribute('open', force);
    if (this.hasAttribute('persist')) this.#write(this.hasAttribute('open'));
    this.dispatchEvent(new CustomEvent('jl-panel:toggle', {
      bubbles: true, detail: { open: this.hasAttribute('open') },
    }));
  }

  #sync() {
    const open = this.hasAttribute('open');
    this.button.setAttribute('aria-expanded', String(open));
    this.body.hidden = !open;
  }

  #read() {
    try { const v = localStorage.getItem(KEY + this.id); return v === null ? null : v === '1'; }
    catch { return null; }
  }

  #write(open) {
    try { localStorage.setItem(KEY + this.id, open ? '1' : '0'); } catch { /* private mode */ }
  }
}

if (!customElements.get('jl-panel')) customElements.define('jl-panel', JlPanel);

The module ID makes the storage key unique, so a user who collapses "Latest News" keeps it collapsed on every page where that module appears.

Why chrome beats a template override here

  • One file, every module. Overrides are per-module-type; a chrome covers Custom HTML, Menu, Articles and any third-party module at once.
  • Server and client stay in one place. The chrome decides markup; the component decides behaviour. Neither needs to know which module it wraps.
  • No-JS safe. Without JavaScript the body is never hidden, because only the component sets hidden. Content is always reachable and indexable.

Example 3: reusable UI patterns

Once you have two or three components, the real win is reuse across extensions. This section covers three patterns that pay off on every Joomla project: a shared base library, a self-loading data element backed by com_ajax, and a form-associated custom field.

Pattern A: one shared library, many extensions

Put shared code in its own media folder — a library package (lib_jlui) installed once and listed as a dependency by your modules, plugins and templates.

media/lib_jlui/js/jl-element.js:

export class JlElement extends HTMLElement {
  /** Read a Joomla language string, falling back to the key. */
  t(key) { return window.Joomla?.Text?._(key) ?? key; }

  /** Typed attribute helpers. */
  num(name, fallback = 0) { const v = Number(this.getAttribute(name)); return Number.isFinite(v) ? v : fallback; }
  bool(name) { return this.hasAttribute(name); }

  /** Bubbling, namespaced events: jl-<tag>:<name>. */
  emit(name, detail = {}) {
    return this.dispatchEvent(new CustomEvent(`${this.localName}:${name}`, { bubbles: true, composed: true, detail }));
  }

  /** Joomla's CSRF token as a ready-to-use query param. */
  get tokenParam() { const tk = window.Joomla?.getOptions('csrf.token'); return tk ? `&${tk}=1` : ''; }

  static define(tag) { if (!customElements.get(tag)) customElements.define(tag, this); }
}

Register it with a bare-name import so consumers never hardcode paths. Joomla 5 added import map support to the Web Asset Manager; mark the asset with "importmap": true:

{
  "name": "lib_jlui",
  "assets": [
    { "name": "jlui.element", "type": "script", "uri": "lib_jlui/jl-element.js",
      "importmap": true, "version": "auto" }
  ]
}

Consumers declare the dependency and import by asset name:

import { JlElement } from 'jlui.element';

class JlRemote extends JlElement { /* … */ }
JlRemote.define('jl-remote');
{ "name": "mod_stats.remote", "type": "script", "uri": "mod_stats/jl-remote.js",
  "dependencies": ["core", "jlui.element"], "attributes": { "type": "module" } }

The consumer must load the library's registry first with $wa->getRegistry()->addExtensionRegistryFile('lib_jlui'). Verify the import map output on your exact Joomla version; on Joomla 4 fall back to a relative import such as ../../lib_jlui/js/jl-element.js.

Pattern B: a self-loading data element via com_ajax

<jl-remote> fetches JSON from Joomla and renders it into a <template> the PHP side provides. Markup stays in the layout (overridable by site builders); the element only moves data.

The layout:

<jl-remote src="/index.php?option=com_ajax&plugin=jlstats&group=ajax&format=json"
           refresh="60">
    <p class="jl-remote__loading"><?php echo Text::_('PLG_AJAX_JLSTATS_LOADING'); ?></p>
    <template>
        <dl class="jl-stats">
            <dt><?php echo Text::_('PLG_AJAX_JLSTATS_ARTICLES'); ?></dt><dd data-field="articles"></dd>
            <dt><?php echo Text::_('PLG_AJAX_JLSTATS_USERS'); ?></dt><dd data-field="users"></dd>
        </dl>
    </template>
</jl-remote>

The plugin (plg_ajax_jlstats, Joomla 5 SubscriberInterface style):

namespace Acme\Plugin\Ajax\Jlstats\Extension;

defined('_JEXEC') or die;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;

final class Jlstats extends CMSPlugin implements SubscriberInterface
{
    use DatabaseAwareTrait;

    public static function getSubscribedEvents(): array
    {
        return ['onAjaxJlstats' => 'onAjaxJlstats'];
    }

    public function onAjaxJlstats(Event $event): void
    {
        $db = $this->getDatabase();

        $count = fn (string $table, string $where) => (int) $db->setQuery(
            $db->getQuery(true)->select('COUNT(*)')->from($db->quoteName($table))->where($where)
        )->loadResult();

        $result   = $event->getArgument('result', []);
        $result[] = [
            'articles' => $count('#__content', 'state = 1'),
            'users'    => $count('#__users', 'block = 0'),
        ];
        $event->setArgument('result', $result);
    }
}

With format=json, com_ajax wraps plugin results in Joomla's JsonResponse: { success, message, messages, data: [ …one entry per plugin… ] }.

The element:

import { JlElement } from 'jlui.element';

class JlRemote extends JlElement {
  #timer; #ctrl;

  connectedCallback() {
    this.tpl = this.querySelector(':scope > template');
    this.load();
    const every = this.num('refresh');
    if (every > 0) this.#timer = setInterval(() => this.load(), every * 1000);
  }

  disconnectedCallback() { clearInterval(this.#timer); this.#ctrl?.abort(); }

  async load() {
    this.#ctrl?.abort();
    this.#ctrl = new AbortController();
    this.setAttribute('aria-busy', 'true');
    try {
      const res = await fetch(this.getAttribute('src') + this.tokenParam, {
        signal: this.#ctrl.signal, headers: { Accept: 'application/json' },
      });
      const json = await res.json();
      if (!json.success) throw new Error(json.message);
      this.render(json.data?.[0] ?? {});
      this.emit('loaded', json.data?.[0]);
    } catch (err) {
      if (err.name !== 'AbortError') this.emit('error', { message: err.message });
    } finally {
      this.removeAttribute('aria-busy');
    }
  }

  render(data) {
    const view = this.tpl.content.cloneNode(true);
    view.querySelectorAll('[data-field]').forEach((el) => {
      el.textContent = data[el.dataset.field] ?? '';
    });
    [...this.children].forEach((c) => c !== this.tpl && c.remove());
    this.append(view);
  }
}

JlRemote.define('jl-remote');

The element writes with textContent, never innerHTML, so a value from the database cannot inject markup. For endpoints that change data, use POST and check the token server-side with Session::checkToken().

Pattern C: a form-associated custom field

Form-associated custom elements take part in native forms: their value is posted with FormData, they support required, and they react to form reset. That makes them a clean fit for Joomla FormField classes, in the admin or on the front end.

The field class:

namespace Acme\Component\Reviews\Administrator\Field;

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;

class RatingField extends FormField
{
    protected $type = 'Rating';

    protected function getInput(): string
    {
        Factory::getApplication()->getDocument()->getWebAssetManager()
            ->useScript('com_reviews.rating');

        return sprintf(
            '<jl-rating id="%s" name="%s" value="%d" max="%d"%s></jl-rating>',
            htmlspecialchars($this->id, ENT_QUOTES),
            htmlspecialchars($this->name, ENT_QUOTES),
            (int) $this->value,
            (int) ($this->element['max'] ?? 5),
            $this->required ? ' required' : ''
        );
    }
}

In the form XML:

<field name="rating" type="rating" max="5" required="true"
       label="COM_REVIEWS_FIELD_RATING_LABEL"
       addfieldprefix="Acme\Component\Reviews\Administrator\Field" />

The element:

class JlRating extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #initial = 0;

  get value() { return Number(this.getAttribute('value')) || 0; }
  set value(v) { this.setAttribute('value', String(v)); this.#commit(); }

  connectedCallback() {
    this.#initial = this.value;
    this.setAttribute('role', 'radiogroup');
    const max = Number(this.getAttribute('max')) || 5;
    this.replaceChildren(...Array.from({ length: max }, (_, i) => {
      const b = document.createElement('button');
      b.type = 'button';
      b.textContent = '★';
      b.setAttribute('role', 'radio');
      b.setAttribute('aria-label', `${i + 1} / ${max}`);
      b.addEventListener('click', () => { this.value = i + 1; });
      return b;
    }));
    this.#commit();
  }

  formResetCallback() { this.value = this.#initial; }

  #commit() {
    const v = this.value;
    this.#internals.setFormValue(v ? String(v) : null);
    this.hasAttribute('required') && !v
      ? this.#internals.setValidity({ valueMissing: true }, 'Please choose a rating', this.querySelector('button') ?? undefined)
      : this.#internals.setValidity({});
    this.querySelectorAll('button').forEach((b, i) => {
      b.setAttribute('aria-checked', String(i + 1 === v)); // one radio checked
      b.classList.toggle('is-filled', i < v);              // stars up to v look filled
    });
  }
}

if (!customElements.get('jl-rating')) customElements.define('jl-rating', JlRating);

jform[rating] now arrives in the controller like any input, and Joomla's form filtering and table binding work unchanged. Replace the hardcoded validity message with a Joomla.Text._() string before shipping.

One caveat: Joomla's legacy client-side validator (form-validate / document.formvalidator) scans regular inputs. It will not mark the custom element invalid on its own, so rely on native validity plus Joomla's server-side form validation, which still runs on save.

Example 4: Shadow DOM in Joomla extensions

Shadow DOM gives a component private markup and CSS that the page cannot break — and that the page cannot style either. In Joomla that trade-off decides everything, so choose deliberately.

Use Shadow DOM when

Stay in light DOM when

The widget must look identical under any template (Cassiopeia, Helix, YOOtheme, Gantry)

Content should follow the site's typography and Bootstrap classes

Aggressive template CSS keeps breaking your extension (button {…}, * { box-sizing }, global resets)

The component wraps module or article content (like jl-panel)

You ship a commercial extension and want fewer support tickets

Third-party scripts must find elements with querySelector

Internal structure is an implementation detail

Inputs must post with a surrounding Joomla form (unless form-associated)

Most core joomla-* elements render into the light DOM so Atum and Cassiopeia styles apply. Treat Shadow DOM as the exception for self-contained widgets, not the default.

A themeable card: <jl-card>

The component exposes three styling hooks, in order of preference: CSS custom properties (they inherit through the shadow boundary), ::part() for specific internals, and slots, whose content stays in the light DOM and keeps template styling.

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host {
    display: block;
    --_accent: var(--jl-card-accent, var(--cassiopeia-color-primary, var(--bs-primary, #0d6efd)));
    --_radius: var(--jl-card-radius, var(--bs-border-radius, .5rem));
    border: 1px solid color-mix(in srgb, var(--_accent) 25%, transparent);
    border-radius: var(--_radius);
    overflow: hidden;
    font: inherit;
    color: inherit;
  }
  :host([hidden]) { display: none; }
  header { padding: 1rem 1.25rem; border-block-end: 3px solid var(--_accent); }
  .body { padding: 1rem 1.25rem; }
  footer { padding: 0 1.25rem 1rem; }
  ::slotted(img) { display: block; width: 100%; height: auto; }
`);

const html = `
  <slot name="media"></slot>
  <header part="header"><slot name="heading"></slot></header>
  <div class="body" part="body"><slot></slot></div>
  <footer part="footer"><slot name="actions"></slot></footer>
`;

class JlCard extends HTMLElement {
  constructor() {
    super();
    // A declarative shadow root rendered by PHP may already exist.
    const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
    root.adoptedStyleSheets = [sheet];
    if (!root.hasChildNodes()) root.innerHTML = html;
  }
}

if (!customElements.get('jl-card')) customElements.define('jl-card', JlCard);

One CSSStyleSheet object is shared by every card on the page — fifty cards, one parsed stylesheet. That is the main performance reason to prefer adoptedStyleSheets over a <style> element per instance.

Theming it from the Joomla template

Site builders style the card from the template's user.css without touching the extension:

/* media/templates/site/cassiopeia/css/user.css */
jl-card { --jl-card-accent: #b5121b; --jl-card-radius: 0; }
jl-card::part(header) { background: #fafafa; text-transform: uppercase; }
.sidebar-right jl-card { --jl-card-accent: var(--cassiopeia-color-link); }

The fallback chain in :host (--jl-card-accent → --cassiopeia-color-primary → --bs-primary → hardcoded) means the card adopts the template's brand colour automatically, yet stays overridable. Check which custom properties your target templates expose; Cassiopeia and Bootstrap 5 both define them on :root.

Server-render the shadow root with a JLayout

Declarative Shadow DOM lets PHP output the shadow root directly. The card is styled and laid out before any JavaScript loads, which removes the flash of unstyled content and helps Core Web Vitals. Package it as a shared JLayout so any extension can render cards.

layouts/jlui/card.php (in your library, or overridable at templates/<tpl>/html/layouts/jlui/card.php):

<?php
defined('_JEXEC') or die;

/** @var array $displayData */
$e = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?>
<jl-card>
    <template shadowrootmode="open">
        <link rel="stylesheet" href="/<?php echo $e($displayData['css']); ?>">
        <slot name="media"></slot>
        <header part="header"><slot name="heading"></slot></header>
        <div class="body" part="body"><slot></slot></div>
        <footer part="footer"><slot name="actions"></slot></footer>
    </template>

    <h3 slot="heading"><?php echo $e($displayData['title']); ?></h3>
    <?php echo $displayData['body']; // trusted, already-rendered HTML ?>
    <?php if (!empty($displayData['link'])) : ?>
        <a slot="actions" class="btn btn-primary" href="/<?php echo $e($displayData['link']); ?>">
            <?php echo $e($displayData['linkText']); ?>
        </a>
    <?php endif; ?>
</jl-card>

Call it from any module, plugin or component view:

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;

echo LayoutHelper::render('jlui.card', [
    'title'    => $item->title,
    'body'     => $item->introtext,
    'link'     => $item->link,
    'linkText' => Text::_('JGLOBAL_READ_MORE'),
    'css'      => HTMLHelper::_('stylesheet', 'lib_jlui/jl-card.css', ['pathOnly' => true, 'relative' => true]),
]);

The slotted <a class="btn btn-primary"> lives in the light DOM, so it keeps Bootstrap and template styling while the card frame stays encapsulated. That split — encapsulated frame, template-styled content — is the sweet spot for Shadow DOM in Joomla.

Three rules for declarative shadow roots:

  • Emit them from layouts, never from editor content. TinyMCE and Joomla's text filters may strip or rewrite <template shadowrootmode> in articles.
  • The parser only honours them on initial page load. Content injected later with innerHTML will not create a shadow root; use Element.setHTMLUnsafe() or build it in JS.
  • The constructor must tolerate an existing root. That is why JlCard checks this.shadowRoot before calling attachShadow().

What breaks at the shadow boundary

  • Events. Events from inside the shadow root are retargeted to the host. Custom events need composed: true to escape (the JlElement.emit() helper above sets it).
  • ARIA references. aria-labelledby and aria-describedby cannot point across the boundary. Keep labels and their targets on the same side, or use ElementInternals ARIA properties.
  • Forms. An <input> inside a shadow root is invisible to the surrounding <form>. Use a form-associated element (Pattern C) or slot a light-DOM input.
  • Content Security Policy. If you enable CSP in Joomla's HTTP Headers plugin, a <style> element inside a shadow root counts as an inline style and needs 'unsafe-inline' or a nonce. A <link> to a file in /media or adoptedStyleSheets avoids the issue. When the plugin's nonce option is on, Joomla exposes the value as $app->get('csp_nonce') for any inline tags you must keep.
  • Global JS that queries the page. Analytics, cookie-consent and lightbox scripts using document.querySelectorAll() will not see shadow content. Slot anything they need to find.
  • Dark mode. Atum and many site templates switch colour schemes by changing custom properties. Consume those variables instead of hardcoding colours, and test both schemes.

Pitfalls and a shipping checklist

Most web component bugs in Joomla come from the CMS side, not the browser. These are the ones that show up on real sites:

Symptom

Cause

Fix

NotSupportedError: … has already been defined

Script loaded twice (override + module, or a page builder)

Guard every define() with customElements.get()

Element never upgrades from a module

Registry file not loaded

addExtensionRegistryFile('mod_x') before useScript()

Joomla is not defined

Asset missing the core dependency

Add "dependencies": ["core"]

Strings show as MOD_X_KEY

Forgot Text::script() on the PHP side

Call it in the layout or dispatcher for every key used in JS

Custom tag stripped from article text

Editor or Joomla text filter removes unknown tags

Render components from layouts, modules or a content plugin, not the editor

Works on first load, dead after AJAX/infinite scroll

Init code ran on DOMContentLoaded

Put all setup in connectedCallback; it runs for injected markup too

Template CSS has no effect on the widget

Content is inside a shadow root

Expose custom properties and ::part(), or move content to slots

Layout jumps when JS loads

Custom elements default to display: inline and unstyled

Set display and reserve space; style :not(:defined)

Duplicate timers, memory growth

No cleanup when module is removed

Clear intervals and abort fetches in disconnectedCallback

Share this article