The Anatomy of a Custom Element — And How Liferay Uses Web Standards to Stay Framework-Agnostic
A guided tour through the Web Components standard as Liferay uses it — the class HTMLElement contract, the four lifecycle callbacks, and the small set of Liferay-specific hooks (SPA navigation, ThemeDisplay, authToken) that make a Custom Element feel native to DXP without dragging in a framework runtime. Explains why the standard is the "smart" choice: any team can bring React, Vue, Svelte, vanilla, or nothing at all, and they all coexist on the same page.
Liferay's Custom Element Client Extension type is a small idea with a big consequence: rather than commit the platform to any JavaScript framework — React, Vue, Svelte, whatever — Liferay bet on the Web Components standard that ships in every modern browser. This post walks through what a Custom Element is, the four lifecycle callbacks that make it work, and the small set of Liferay integrations layered on top. The payoff: any team can bring any framework (or none), and they all coexist on the same page without stepping on each other.
##1. The framework-lock-in problem Liferay avoided
Every CMS-and-portal platform eventually faces a fork: do we pick a canonical JavaScript framework and require everyone to use it, or do we stay framework-agnostic and let each team decide? Both routes have real costs.
Picking a canonical framework means shipping its runtime with the platform, defining component conventions around its idioms, and living with the migration pain when the framework releases a major version that breaks patterns everyone learned. It also excludes anyone whose team already invested in a different framework.
Staying framework-agnostic means finding a component boundary that every framework — and no framework — can produce. That boundary existed already, sitting in the browser standard, waiting for someone to adopt it: Web Components. Liferay did.
##2. What a Custom Element actually is
A Custom Element is a browser-native way to register a new HTML tag that behaves like a first-class DOM element. Once registered, you can put <my-widget></my-widget> anywhere on a page and the browser calls your JavaScript to render it, handle events, and clean up when it's removed.
The public surface is small — three names to know:
HTMLElement— the base class every native element (buttons, divs, anchors) inherits from. Your custom element extends this.customElements.define(tagName, class)— the global registry that binds a tag name to a class.- Lifecycle callbacks — methods the browser calls at key moments (added to page, removed, attribute changed).
That's the entire API. No JSX. No virtual DOM. No render function. No hooks. Just class + tag name + a few methods the browser will call for you.
##3. The contract — HTMLElement + customElements.define
The absolute minimum viable Custom Element is 5 lines of JavaScript:
class HelloWorld extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello, world!';
}
}
customElements.define('hello-world', HelloWorld);Import this file on any HTML page, put <hello-world></hello-world> in the markup, and the browser calls connectedCallback the moment the tag enters the DOM. Your class handles rendering, event listeners, cleanup — all inside this, which is the actual DOM element the user sees.
Two conventions the standard requires:
- The tag name must contain a hyphen (
hello-world, nothello). This is how the browser tells your custom tags apart from future native HTML additions. - The class must extend
HTMLElementdirectly (or another element likeHTMLButtonElementfor "customized built-ins", but plainHTMLElementis the common path).
##4. The four lifecycle callbacks
The browser calls up to four methods on your class as the element moves through its life. All are optional; implement the ones you need.
###connectedCallback() — added to the page
Fires when the element enters the DOM. This is where you render, attach event listeners, fetch initial data, start timers. Called every time the element is (re-)inserted into the document, not just the first time — so if code moves the element around, this can fire multiple times.
###disconnectedCallback() — removed from the page
Fires when the element is removed from the DOM. This is where you tear down what connectedCallback set up: unregister event listeners, cancel in-flight fetches, clear intervals. Critical for avoiding memory leaks in SPAs where widgets get inserted and removed as users navigate.
###attributeChangedCallback(name, oldValue, newValue) — attribute updated
Fires when an HTML attribute in your observedAttributes array changes. This is how you make widgets configurable from the outside — <my-widget theme="dark"> versus <my-widget theme="light">. The browser watches the attributes you declared and calls this method whenever one flips.
###adoptedCallback() — moved to a new document
Fires when the element is moved between documents (typically via an <iframe>). Rare in practice — most widgets never implement this. Mentioned for completeness.
##5. Anatomy of a minimal Custom Element
Every non-trivial Custom Element follows the same skeleton — render on connect, clean up on disconnect, respond to attributes if needed:
class FeatureCard extends HTMLElement {
// Declare which attributes to watch. Omit if the widget takes no attributes.
static observedAttributes = ['title', 'theme'];
constructor() {
super();
// Set up class-level fields here. NOT the DOM — the element isn't in the
// page yet. Keep this small; browser calls it during upgrade.
this._clickHandler = () => this._onClick();
}
connectedCallback() {
this._render();
this.addEventListener('click', this._clickHandler);
}
disconnectedCallback() {
this.removeEventListener('click', this._clickHandler);
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
// Attributes can change before or after connect. If we're already in the
// DOM, re-render. Otherwise connectedCallback will handle initial render.
if (this.isConnected) this._render();
}
_render() {
const title = this.getAttribute('title') ?? 'Untitled';
const theme = this.getAttribute('theme') ?? 'light';
this.className = `feature-card feature-card--${theme}`;
this.innerHTML = `
<h3 class="feature-card__title">${title}</h3>
<div class="feature-card__body"><slot></slot></div>
`;
}
_onClick() {
this.dispatchEvent(new CustomEvent('feature-card:clicked', { bubbles: true }));
}
}
customElements.define('feature-card', FeatureCard);Everything a full-featured widget needs is here in ~30 lines. No framework, no dependencies, no build step. Drop this file on any page, use <feature-card title="Hello" theme="dark"> wherever you want, and it renders, reacts to attribute changes, and cleans up when removed.
##6. How Liferay plugs a Custom Element into a page
Liferay's customElement Client Extension is thin — its job is to (1) know your widget exists, (2) inject the JS that defines it on any page that uses it, and (3) let content editors drop the widget via the widget palette or a Fragment.
feature-card:
type: customElement
name: Feature Card
htmlElementName: feature-card # must match customElements.define(...)
urls:
- /o/feature-card/feature-card.js
instanceable: true # editors can drop multiple copies
useESM: true # served as an ES module
portletCategoryName: category.remote-apps
properties:
- "Widget=true" # show up in the widget paletteThree things Liferay does with this manifest:
- Registers
feature-cardas an available widget in the Page Editor palette. - When a page renders that includes this widget (either directly or via a Fragment that embeds it), Liferay injects a
<script src="/o/feature-card/feature-card.js">tag into the page HTML. - Deduplicates the script inclusion — even if 10 instances of the widget appear on one page, the JS loads once.
That's the entire integration surface. Once your JS runs and calls customElements.define(...), the browser takes over. Every <feature-card> tag on the page gets its connectedCallback called. Liferay is out of the loop.
##7. The small set of Liferay-specific hooks
Where Liferay adds value beyond the raw standard is a handful of optional global helpers your Custom Element can feature-detect and use. All are opt-in — a widget that doesn't touch them still works.
###Liferay.ThemeDisplay — page + user context
Provides the currently signed-in user, the active site, the layout, the theme, and dozens of other page-level facts. Feature-detect and use as needed:
connectedCallback() {
const td = typeof Liferay !== 'undefined' ? Liferay.ThemeDisplay : undefined;
const userId = td?.getUserId?.() ?? null;
const isSignedIn = td?.isSignedIn?.() ?? false;
if (!isSignedIn) {
this.textContent = 'Please sign in.';
return;
}
this._loadForUser(userId);
}###Liferay.authToken — CSRF for Headless calls
When calling Liferay Headless APIs from the browser, DXP requires the session's CSRF token in the x-csrf-token header. Available as a global:
const response = await fetch('/o/headless-admin-user/v1.0/my-user-account', {
headers: {
'Content-Type': 'application/json',
'x-csrf-token': Liferay.authToken,
},
});###Liferay.on('endNavigate', ...) — SPA-aware widgets
Liferay pages behave like a single-page application — navigating between pages doesn't always trigger a full reload. If your widget caches user-specific state, listen to Liferay's navigation event to re-hydrate on route changes:
connectedCallback() {
this._render();
this._onNavigate = () => this._render();
if (typeof Liferay !== 'undefined' && typeof Liferay.on === 'function') {
Liferay.on('endNavigate', this._onNavigate);
}
}
disconnectedCallback() {
if (typeof Liferay !== 'undefined' && typeof Liferay.detach === 'function') {
Liferay.detach('endNavigate', this._onNavigate);
}
}###The customElements.get guard
Because Liferay's SPA may re-import your module during navigation, calling customElements.define a second time would throw. Every Liferay-friendly Custom Element ends with a guard:
if (!customElements.get('feature-card')) {
customElements.define('feature-card', FeatureCard);
}##8. Shadow DOM vs light DOM — Liferay chose light
Web Components let you render into either Shadow DOM (an isolated tree, scoped styles, hidden from outer CSS) or light DOM (the normal, inspectable DOM, subject to page-level styles). Liferay's reference sample (and CX Composer's scaffold) uses light DOM:
_render() {
this.classList.add('feature-card');
this.innerHTML = `<h3>Hello</h3>`;
// No this.attachShadow() — content lives in the normal DOM tree
}Why light DOM by default in Liferay:
- Liferay's theme CSS can style your widget without any special selectors. The site's design tokens, brand colours, typography — all apply automatically.
- Content editors can inline-edit slotted content via
data-lfr-editable-*markers in a companion Fragment (covered in the inline-editable Custom Element post). - Devtools inspection is straightforward — no shadow root to expand.
Use Shadow DOM if you actively want your widget isolated from the page's styles (a third-party embed, a library-mode component where consistent appearance is more important than theming). Both work with the Client Extension model.
##9. What you can bring — React, Vue, Svelte, or nothing
This is where the framework-agnostic bet pays off. The Web Components standard cares only about the outer contract — extending HTMLElement, calling customElements.define. What happens inside connectedCallback is entirely your choice.
###Vanilla — no framework at all
Hand-written innerHTML, template strings, DOM APIs. Smallest bundle. Zero runtime. Best fit for widgets that need the browser and nothing else. This is the pattern CX Composer scaffolds by default — matches Liferay's liferay-sample-custom-element-4 reference.
###React inside a Custom Element
Mount a React root inside connectedCallback, unmount inside disconnectedCallback. Ship the React runtime as part of your widget bundle:
import { createRoot } from 'react-dom/client';
import App from './App';
class ReactWidget extends HTMLElement {
connectedCallback() {
this._root = createRoot(this);
this._root.render(<App />);
}
disconnectedCallback() {
this._root?.unmount();
}
}
customElements.define('react-widget', ReactWidget);###Vue, Svelte, Solid, any other framework
Same pattern — instantiate the framework's app inside connectedCallback, destroy it inside disconnectedCallback. Vue's createApp(App).mount(this), Svelte's new App({ target: this }), etc.
##10. The payoff — every team, one page
The consequence of Liferay committing to Web Components rather than a specific framework is architectural. It means:
- Different teams, different stacks. A designer's team can ship vanilla Custom Elements for content-marketing widgets. A product team can ship React widgets for a dashboard. A commerce team can ship Vue widgets for checkout. All three coexist on the same Liferay page. No team blocks another.
- No platform-wide framework migration. When React 20 breaks its patterns or Vue 5 renames a method, only the widgets built on that framework need updating. Liferay itself doesn't need to migrate.
- Long-term stability. The Web Components standard is stable browser API. Custom Elements shipped in 2018. They'll still work identically in 2030. Widgets you write today don't face a "end of life for the framework we chose" deadline.
- Zero framework tax on Liferay. Liferay ships no React, no Vue, no Svelte runtime as part of DXP. If a page has no widgets that use them, no framework bytes get shipped to the user's browser.
- Composability that matches ownership. Each Custom Element is an independent, deployable unit. Teams own their widget end-to-end — code, build, styles, runtime dependencies. No shared frontend monorepo bottleneck.
It's a small technical decision — "we'll build on the browser standard, not a framework" — with a large architectural payoff. Liferay bet correctly.
Scaffold this in 10 seconds with CX Composer.
VS Code extension for Liferay developers. Wizard-driven scaffolding for all 28 Client Extension types, IntelliSense on client-extension.yaml, five layered validators, and per-extension build via Blade.