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.
connectedCallbackfires whenever the element enters the DOM — on page load, after an AJAX-injected module, inside a modal. No moreDOMContentLoadedplus 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:
- A custom element shipped by a site module, loaded through the Web Asset Manager.
- A module chrome that wraps any module in a collapsible web component.
- Reusable patterns: a self-loading data element backed by
com_ajax, and a form-associated custom field. - 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,importallowed."dependencies": ["core"]guaranteesJoomla.getOptions()andJoomla.Textexist before your code runs.- The
uriomitsjs/andcss/. WAM resolvesmod_wcdemo/x.jstomedia/mod_wcdemo/js/x.jsand also picks up a.min.jssibling 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 |
|
|
Translated strings |
|
|
|
Page-wide settings |
|
|
|
CSRF token |
added by core |
|
|
Content |
child markup (light DOM) |
slots or |
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 ondocumentforjl-countdown:donewithout 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 params: |
|
|
|
attributes from |
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 ( |
The component wraps module or article content (like |
|
You ship a commercial extension and want fewer support tickets |
Third-party scripts must find elements with |
|
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
innerHTMLwill not create a shadow root; useElement.setHTMLUnsafe()or build it in JS. - The constructor must tolerate an existing root. That is why
JlCardchecksthis.shadowRootbefore callingattachShadow().
What breaks at the shadow boundary
- Events. Events from inside the shadow root are retargeted to the host. Custom events need
composed: trueto escape (theJlElement.emit()helper above sets it). - ARIA references.
aria-labelledbyandaria-describedbycannot point across the boundary. Keep labels and their targets on the same side, or useElementInternalsARIA 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/mediaoradoptedStyleSheetsavoids 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 |
|---|---|---|
|
|
Script loaded twice (override + module, or a page builder) |
Guard every |
|
Element never upgrades from a module |
Registry file not loaded |
|
|
|
Asset missing the |
Add |
|
Strings show as |
Forgot |
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 |
Put all setup in |
|
Template CSS has no effect on the widget |
Content is inside a shadow root |
Expose custom properties and |
|
Layout jumps when JS loads |
Custom elements default to |
Set |
|
Duplicate timers, memory growth |
No cleanup when module is removed |
Clear intervals and abort fetches in |