cx-composer
← blog11 min read

Inline-Editable Custom Elements in Liferay Using Slots

A pattern for building reusable Liferay Custom Element widgets whose content editors can inline-edit in the Page Editor. Uses Web Component <slot> elements mapped to Liferay editable regions via a companion Fragment. Full walkthrough with the client-extension.yaml, the Custom Element source, the Fragment wrapper, and how it all deploys together.

tutorialcustom elementfragmentspage editorliferay dxp

Liferay's Custom Element Client Extension is the fastest way to drop a reusable web-component widget onto any page. The problem: content editors can't edit the copy inside without asking a developer to rebuild the JS. This post shows a pattern that gives you both — a single reusable Custom Element that content editors can inline-edit in the Page Editor — by combining Web Component <slot> elements with Liferay's Fragment editable-region markers.

##1. The gap between widgets and editable content

You've built a "Feature Card" widget. It has a heading, a paragraph, a call-to-action button, and some smart behavior — maybe an animated icon or a hover state. You want it dropped onto ten different pages, each with different copy.

With a plain Custom Element, the copy lives inside the JS file. Every change is a code change. Content editors can't touch it. With a plain Fragment, the copy is editable — but you lose the encapsulation and behavior of a web component, and you end up repeating markup + styles across every Fragment that needs it.

Neither approach alone gets you what a component-based CMS should deliver: developers own the shell (structure + behavior + styles), editors own the content. The pattern below closes that gap.

##2. Two half-solutions today

###Option A — Fragment only

A Liferay Fragment is a chunk of HTML/CSS with data-lfr-editable-id and data-lfr-editable-type markers. Content editors can click any marked element in the Page Editor and edit it inline. Great for editability.

The downside: everything is HTML. No shadow DOM, no scoped CSS, no custom-element lifecycle callbacks, no encapsulated state. If your widget has any real interactivity — a carousel, an animated counter, a smart form — the Fragment path forces you to sprinkle <script> tags and pray no other Fragment on the page conflicts.

And fragments don't deploy alongside code — you either upload them one at a time in the admin UI or ship a Site Initializer. Reuse across instances is manual.

###Option B — Custom Element only

A customElement Client Extension registers a web component (e.g. <feature-card>) that content editors can drop as a widget from the widget palette. It ships alongside your other code, deploys via blade gw buildClientExtensionZip, and everything is properly encapsulated.

The downside: what shows up inside <feature-card> is whatever the JS renders. If the heading text is hard-coded in the component's connectedCallback, editors have zero purchase on it. Every copy change is a redeploy.

You can pass content via HTML attributes (<feature-card title="Hello">) — but that's clunky, doesn't support rich text, and the Page Editor UI has no idea how to expose those attributes as editable fields.

##3. The pattern: Custom Element + slots + Fragment

The key insight is that Web Components already have a mechanism for "the developer defines the shell, the caller provides the content": the <slot> element. And Liferay Fragments already have a mechanism for "the developer defines the markup, the editor edits specific regions": data-lfr-editable-* attributes.

Combine them:

  • The customElement Client Extension defines the shell — styles, structure, behavior — and declares one or more named <slot> elements where content plugs in.
  • A companion Fragment renders the Custom Element with editable <div>s inside, each tagged with slot="name" to feed the right slot and data-lfr-editable-id + data-lfr-editable-type so the Page Editor lets a content editor click and edit.
  • When an editor drops the Fragment onto a page, they see the fully rendered widget — and can inline-edit any of its content regions without touching code.

##4. Build the reusable Custom Element

Start with a simple "Feature Card" component. Three slots: heading, body, and CTA link.

❯ feature-card.jsjs
class FeatureCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 1.5rem;
          border: 1px solid #e5e7eb;
          border-radius: 0.5rem;
          background: #fff;
          transition: box-shadow 0.2s ease, transform 0.2s ease;
        }
        :host(:hover) {
          box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
          transform: translateY(-2px);
        }
        .headline {
          font-size: 1.25rem;
          font-weight: 600;
          margin-bottom: 0.5rem;
        }
        .body {
          font-size: 0.95rem;
          line-height: 1.55;
          color: #52525b;
          margin-bottom: 1rem;
        }
        ::slotted(a) {
          display: inline-block;
          padding: 0.5rem 1rem;
          border-radius: 0.375rem;
          background: #4f46e5;
          color: white;
          text-decoration: none;
          font-weight: 500;
        }
        ::slotted(a:hover) { background: #4338ca; }
      </style>
      <div class="headline"><slot name="headline">Card headline</slot></div>
      <div class="body"><slot name="body">Body copy goes here.</slot></div>
      <div class="cta"><slot name="cta"></slot></div>
    `;
  }
}

customElements.define('feature-card', FeatureCard);

Three important pieces here:

  1. attachShadow({ mode: 'open' }) — Shadow DOM keeps our styles scoped so they don't leak to (or from) the surrounding page.
  2. Each <slot name="…"> is a placeholder. The text inside a slot (Card headline) is fallback content that shows only when the caller provides nothing.
  3. ::slotted(a) styles slotted content from the outside — the slotted node itself lives in light DOM, but we can dress it up from our Shadow DOM styles.

##5. Register it as a Client Extension

The Custom Element ships as a normal customElement Client Extension. Nothing special.

❯ client-extension.yamlyaml
feature-card:
  type: customElement
  name: Feature Card
  htmlElementName: feature-card
  instanceable: true
  cssURLs: []
  urls:
    - /o/feature-card/feature-card.js
  portletCategoryName: category.remote-apps
  useESM: true
  friendlyURLMapping: feature-card
  properties:
    - "Widget=true"

Two flags worth calling out:

  • instanceable: true — lets content editors drop multiple copies of this widget on the same page. The Fragment pattern below relies on that.
  • useESM: true — tells Liferay to serve the JS with an ES module MIME type. Required so the same file can be imported via the import-map entry we'll add next. Without this, Liferay serves it as a plain script and import 'feature-card' fails.

##6. Expose the JS via a jsImportMapsEntry

There's a subtle problem with using the Custom Element inside a Fragment. When an editor drops <feature-card> as a widget directly from the widget palette, Liferay knows to inject a <script> tag that loads the Custom Element's JS — which is what registers customElements.define(...) in the browser.

But when the same tag lives inside a Fragment's HTML, Liferay does not automatically load the JS. The Fragment renders <feature-card>, the browser sees an unknown tag, and nothing registers it — the element sits inert with just its fallback content.

The fix is to expose the same JS as a second Client Extension of type jsImportMapsEntry. That adds an entry to Liferay's browser import map so any page (including one containing your Fragment) can resolve the bare specifier feature-card to the actual JS URL.

❯ client-extension.yaml (add the import-map entry)yaml
feature-card-js-map:
  type: jsImportMapsEntry
  name: Feature Card (import map)
  bareSpecifier: feature-card
  url: /o/feature-card/feature-card.js
  useESM: true

The three fields that matter:

  • bareSpecifier — the string that scripts use in import '…'. Must be exactly the same name you'll type in the Fragment's import statement.
  • url — the URL Liferay serves the JS at. This is the same file your customElement CX ships at /o/feature-card/feature-card.js — one file, two CX registrations.
  • useESM: true — mirrors the flag on the customElement CX so the file is served as an ES module.

Two Client Extensions in one workspace now:

  • feature-card — the customElement CX (from section 5) that lets editors drop the widget from the widget palette
  • feature-card-js-map — the jsImportMapsEntry CX that makes import 'feature-card' resolvable from any script on the page

At runtime, Liferay renders an <script type="importmap"> block into the page <head> that looks roughly like:

❯ what Liferay injectshtml
<script type="importmap">
{
  "imports": {
    "feature-card": "/o/feature-card/feature-card.js"
  }
}
</script>

Now any <script type="module"> on the page can do import 'feature-card'; and the browser resolves it via the import map — which runs the module and executes the customElements.define(...) at the bottom.

##7. Wrap it in a Fragment and import the element

The Custom Element on its own already works — an editor can drop <feature-card> as a widget and it will render its fallback content. But to make it inline-editable, we wrap it in a companion Fragment.

A Liferay Fragment is a chunk of HTML that the Page Editor renders as a block editors can position anywhere. The HTML can contain any elements — including your Custom Element — and any element with data-lfr-editable-id becomes a click-to-edit region.

❯ feature-card-fragment/index.htmlhtml
<feature-card>
  <div slot="headline" data-lfr-editable-id="headline" data-lfr-editable-type="text">
    Ship faster with our platform
  </div>
  <p slot="body" data-lfr-editable-id="body" data-lfr-editable-type="rich-text">
    Everything your team needs to go from idea to production in a single afternoon.
  </p>
  <a slot="cta" href="/get-started" data-lfr-editable-id="cta" data-lfr-editable-type="link">
    Get started
  </a>
</feature-card>

Three editable regions, each mapped to a slot. The HTML alone doesn't register <feature-card> with the browser yet — for that we need one line in the Fragment's JavaScript file, which Liferay loads as an ES module when the Fragment renders.

❯ feature-card-fragment/index.jsjs
import 'feature-card';

That's the whole file. The browser resolves the bare specifier 'feature-card' via the import map entry we registered in section 6, fetches the Custom Element JS (only once per page even if multiple Fragments do the same import), and runs its top-level customElements.define('feature-card', …).

Now an editor drops the Fragment into a page and sees a fully rendered Feature Card. Clicks the headline — text input. Clicks the body — rich-text editor. Clicks the CTA — link picker with URL + label. All without touching JS.

The Fragment's fragment.json descriptor references both files:

❯ feature-card-fragment/fragment.jsonjson
{
  "fragmentEntryKey": "feature-card",
  "name": "Feature Card",
  "type": "component",
  "cssPath": "index.css",
  "htmlPath": "index.html",
  "jsPath": "index.js",
  "configurationPath": "configuration.json"
}

##8. Editable types — text, rich-text, image, link

data-lfr-editable-type tells the Page Editor which editor UI to pop when the region is clicked. The full set:

  • text — plain-text input. No HTML allowed. Best for short headings and labels.
  • rich-text — full WYSIWYG (bold, italic, lists, links). Best for body copy.
  • image — image picker from the Document Library, with alt-text and dimensions. Use with <img> tags.
  • link — URL + label + target picker. Use with <a> tags — the editor overwrites both the href and the anchor text.
  • html — raw HTML input (advanced; use rarely, edits are not sanitized).
  • backgroundImage — image picker that assigns to CSS background-image on the element instead of an <img>.

A card with all four common types:

❯ feature-card-fragment/index.htmlhtml
<feature-card>
  <img slot="icon"
       data-lfr-editable-id="icon"
       data-lfr-editable-type="image"
       src="/o/feature-card/default-icon.svg"
       alt="Feature icon"
       width="48" height="48">

  <h3 slot="headline"
      data-lfr-editable-id="headline"
      data-lfr-editable-type="text">
    Ship faster
  </h3>

  <div slot="body"
       data-lfr-editable-id="body"
       data-lfr-editable-type="rich-text">
    <p>From idea to production in a single afternoon.</p>
  </div>

  <a slot="cta"
     data-lfr-editable-id="cta"
     data-lfr-editable-type="link"
     href="/get-started">
    Get started
  </a>
</feature-card>

##9. Default fallback content when a slot is empty

The Custom Element's slot fallback content matters more than it looks. It shows up in three situations:

  1. When the widget is dropped directly onto a page (not via the Fragment). Editors see something instead of a blank box.
  2. When the Fragment renders but a specific editable region has never been populated (some editors delete the placeholder text).
  3. When the widget is used programmatically outside the Page Editor — e.g. injected via JS or another framework.

Good fallback content is instructive — it tells whoever sees it what should go there:

❯ feature-card.js (slots with fallback)js
<slot name="headline">Add a headline &lt;h3&gt; slotted here</slot>
<slot name="body">Add body copy &lt;p&gt; slotted here</slot>
<slot name="cta">Add a &lt;a&gt; slotted here</slot>

In production, keep fallbacks short and greyed out via CSS so real content is visually distinct from placeholders:

❯ feature-card.js (fallback styling)js
::slotted(*), slot::before {
  /* real content is normal */
}
slot:not(:has(> *))::before {
  content: attr(name) ' slot — add content in the Page Editor';
  color: #999;
  font-style: italic;
}

##10. When to use this pattern (and when not to)

###Good fit

  • Widgets with real interactive behavior — animations, carousels, timers, smart forms — that also need editable copy.
  • Components used across many pages with different content each time (feature cards, hero banners, testimonial blocks, CTA sections).
  • Widgets where developers own the design system but content teams own the words.

###Not a fit

  • Static content blocks with no behavior — plain fragmentPage is simpler and requires no JS bundle.
  • Widgets fed from an external API where content is never editorially controlled — a plain customElement without slots is fine.
  • When the "widget" is really just a section of a page template — a fragmentComposition handles that natively.

###One more benefit worth calling out

Because the Fragment is a thin HTML wrapper, editors can customize the wrapper markup itself in the Page Editor — rearrange the slotted order, add CSS classes, or wrap regions in additional containers — without breaking the widget. The Custom Element sees whatever gets slotted in, in whatever order. That's the true "developer owns the shell, editor owns everything inside" separation.

─── 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.