cx-composer
← blog9 min read

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.

tutorialweb componentscustom elementliferay dxp

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:

❯ hello-world.jsjs
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:

  1. The tag name must contain a hyphen (hello-world, not hello). This is how the browser tells your custom tags apart from future native HTML additions.
  2. The class must extend HTMLElement directly (or another element like HTMLButtonElement for "customized built-ins", but plain HTMLElement is 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:

❯ feature-card.jsjs
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.

❯ client-extension.yamlyaml
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 palette

Three things Liferay does with this manifest:

  • Registers feature-card as 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:

❯ widget-using-theme-display.jsjs
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:

❯ headless-call.jsjs
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:

❯ spa-aware.jsjs
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:

❯ guarded-define.jsjs
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:

❯ light-dom-render.jsjs
_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:

❯ react-custom-element.jsjs
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.

─── skip the boilerplate ───

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.