Advanced Custom Element Techniques

Custom Elements are the preferred way to build Applications for Liferay. Here are some advanced techniques you can use with your custom elements to take them to the next level...

Introduction

So anyone who knows me can tell you that I'm a huge fan of Custom Elements, especially when used with Liferay Objects.

I prefer to use React for creating the front end and Objects for the persistence layer and, if necessary, some backend CX to complete the picture with integration, notification, and other types of automated requirements.

Using these Liferay features, I can quickly create extensions for Liferay, rather than customizations, and since they live outside of Liferay they are isolated and protected from impact of Liferay upgrades, so they become a future-proof solution for implementations based upon Liferay.

And, since custom elements are nothing more than Standard Web Components, they empower you to do all kinds of really cool and reusable things in your element implementations.

The one problem you'll start to notice as you build these out though is sometimes the lack of direct Liferay page integration with your custom elements. Simple Standard Web Components are typically completely self contained and lack access to the surrounding Liferay context beyond access to the Liferay javascript object.

As you'll soon learn, though, there are lots of things that we do to really make our custom elements a lot more powerful and take advantage of some additional Liferay capabilities.

I've put together a list of what I'm calling "Advanced Custom Element Techniques" because you're not going to find them necessarily in the Liferay Learn documentation (yet), but they all work just fine.

And I'm going to share a repo for all of the components and samples that I'm going to be presenting here. It should go without saying that these are going to be simple custom elements meant to demonstrate each of the techniques. They're not going to be "production" elements (because they don't really do much that will benefit a production environment), but all of the techniques they showcase are absolutely production-capable.

The repo is going to include 8 custom elements and 10 custom fragments, all written to take advantage of Liferay's React 18, will target Liferay CE GA 132 (meaning they'll also work on DXP 2025 Q1+), and will be using Yarn and Vite.

Without further ado, let's start with a quick environment setup!

Environment Preparation

The repo is complete and ready to go, both from a code perspective as well as from a data perspective...

Clone it to your local environment, then use the blade gw initBundle to create a new local bundle.

Then use the blade server start command to start up the new bundle.

Navigate to the client-extensions/advanced-custom-element-techniques folder of the workspace and use the command blade gw clean deploy to build and deploy the custom elements.

Next navigate to the modules/liferay-request-attribute-contributor directory and issue the command blade gw clean deploy. This will publish a few utility template contributors used by a couple of the sample fragments.

The deployment is finished so the code is ready, but let's go ahead and import some other things into the environment

Log into the environment as the administrator. The email address to use is acet-admin@acet.com and the password is learn-acet .

After logging in, go to the Objects control panel choose the Import Object Definition option next to the Default folder name and then import the support-files/objects/ACETCustomObject.json file to create the custom object used in the blog post. This will create a control panel in the Objects section named ACET Custom Objects, so you can navigate there to add new entries. The object itself has a name, description, plus an image.

The examples that I used for images in the blog are for the following ACET Custom Objects:

Name Description Image
Manny Manny has been on or team for 3 years. support-files/images/manny.png
Moe Moe is new to the team but has 15 years of experience. support-files/images/moe.png
Jack Jack is the team lead. support-files/images/jack.png

Note the form supports setting the ERC for each entry. I prefer to use ERCs that have business meaning, so my ERCs are in the form of ACET-Mechanic-Moe. Setting a custom value is not necessary, a UUID will be assigned if you leave the field blank.

With this object, you'll have a Collection Provider for you called ACET Custom Objects that will give you the list of objects.

All of the custom fragments used in the blog are also available. Navigate to the Design -> Fragments site control panel, use the peapod menu next to the Fragment Sets label to Import a fragment set, then import the support-files/fragments/ACET-Fragments.zip file.

With that, your environment is ready to explore the advanced techniques!

ACET #1: Multiple Custom Elements in a Single Client Extension

Although it may not be completely clear, it is perfectly fine to include multiple custom elements in a single client extension. In the Samples Workspace Liferay didn't really do that, and that might lead you to the impression that each custom element must be in its own client extension project.

This is not the case, you can combine your custom elements in a single client extension, and I'm going to be doing that in my demo repo.

Why would you want to do something like this? Well, there's a number of pros and cons...

Pros:

  • Logically related custom elements make sense to keep together.
  • Shared dependencies within the CX so they would be smaller than multiple CX managing the same dependencies, also simpler than creating a separate Import Map CX to manage the shared dependencies.
  • Easier deployment (one CX vs multiple CX).
  • Lower resource usage in SaaS and PaaS since it's only one CX instead of multiple CX.

Cons:

  • Teams could step on each other if they are all working on the same CX.
  • If the CX are not logically related, it may be confusing to group them in one CX.

So whether and what to group into a single CX is a choice that is up to you now that you'll know how to do it. And yes, you'll know how to do it since we're doing it here! The reality of course will be that you'll have multiple CX, but those CX will contain multiple custom elements that are related some how (they share overlapping dependencies, they are created by the same developer/team, they are related by business function, etc), I'm sure you'll figure that part out and, of course, if you have questions, come find me in the Community Slack and ask...

Since all of the custom elements I'm going to share are small, they're related (since they all demonstrate the advanced techniques), they have no real dependencies outside of Liferay's React 18, and I'm only one developer (no possible conflicts), I'm going to put all of my custom elements in a single CX.

In fact, the commands that I used to create the CX are:

$ mkdir advanced-custom-element-techniques
$ cd advanced-custom-element-techniques
$ yarn create vite advanced-custom-element-techniques --template react

This of course only creates the basic React project. I then made some minor changes to it including changing the Vite configuration to exclude React 18 from being bundled (since I'm going to use Liferay's React 18), changing the package.json to fix to Liferay's React 18 version number. Also I had to add the client-extension.yaml file to make the elements available in Liferay.

After that, it was really a matter of creating the necessary component classes and registering the custom elements.

ACET #2: Props

So this is probably the simplest of the advanced techniques, and this one might actually be covered in the documentation on Learn, but since these are used in some of my other examples, I'm going to include it here anyway.

Now, I'm referring to this technique as Props because it is the same name that we use in React to set props for child components. The technique will work in all other frameworks that create Standard Web Components but they might be referred to as a different name, but that doesn't change technique, just what it's called and how you leverage them.

The first custom element that we're creating is <acet-idprops></acet-idprops>, and since we're adding a prop to it, let's pass in an ID, so our tag will be something like <acet-idprops id="###"></acet-idprops>.

So from the React side, the component implementation is going to be pretty simple. The component itself will accept a prop for ID and it will include a default value (in case one is not specified), and it's just going to display the prop value.

Nothing that impressive from a React point of view, it's the fun that we'll have with it shortly that will really matter.

Since it is really small, I'll show the content of the component class here:

const IdProp = ({ id = "42" }) => {
  return (
    <div>
      Passed ID: {id}
    </div>
  );
};

export default IdProp;

Notice that this class does nothing special, it's typical React component code to accept an id prop and use it in the rendering. And here's the relevant snippet from main.jsx where it gets used:

<React.StrictMode>
  <IdProp id={this.getAttribute('id')} />
</React.StrictMode>

Since this is part of the HTMLElement class, it has to extract the attribute value and it then passes that value directly as the prop value to the component.

Now comes the fun, using this tag...

Log into the running bundle as the admin.

Use the credentials acet-admin@acet.com and password learn-acet to log in as an admin. You can find these hard-coded in the portal-ext.properties file, although you should of course never do this in any of your real environments.

Throughout the blog we're going to be creating a number of pages that will put each of the elements to use. For this one, create a new page called IdProp. On the page I drop a container (I always like to have an outer container with margin and padding so I get space around my testing work).

Next I drop the Advanced Custom Element Techniques - IdProp element into the page:


Here we see that the component is on the page, but it's not displaying anything at all for the ID property. Let's take care of that by configuring that value now.

Click on the element, then choose its configuration.

This will bring up the configuration dialog that starts out like:

Click inside of the Properties text area and then enter id=12345

You'll see your value like:

Save the changes and return to the page edit view and you'll see that the presentation has changed:

And, if you publish the page, you'll see that the element renders itself correctly:

And that's it, that's your very first advanced technique! We now have a property that we can set in the UI and pass it through to the custom element, and our React code can take and use that property in whatever way we might need to.

Now this is really a super flexible technique that we're going to expand upon in a minute, but let's take a moment to call out a challenge with this technique - it is not at all friendly to the page creator.

I mean sure, we can pass one (or more) values from the front end to the element itself, but how does the page creator know anything about the props themselves? How do they know I have only one property? How do they know what the valid values or limitations on the values there might be? What if I have added support for say 5 properties, some are constrained and some are freeform?

Using This Technique

Okay, so we've seen the basics, but let's talk about using it in the real world.

First, let's make a couple of things clear:

We can use one or more Props with this technique. There are no limits, they can contain any data you're capable of passing in a simple HTML attribute.

Since they are HTML attributes, the values will appear in the DOM, so no encryption or protection or anything apply. Also if passing complex data, the data needs to be encoded so it is acceptable as an attribute value and, of course, your custom element would need to know how to decode it.

If you want the attribute to have a default value, the place to set that is going to be in the client-extension.yaml file for the custom element entry. This will be the default value when there is no overriding value in the Properties text area for the configuration on the element.

The biggest issue with using props like this is that it is not very friendly for the page creator. There's no documentation on supported properties, there's nothing that limits the values assigned, there's no validation or anything on the values or the keys for that matter. While it is flexible, it is awkward and error prone to use.

But we'll deal with this in some of our subsequent advanced techniques such as the next one.

ACET #3: Use Custom Fragment Configuration

This technique, parts of it we will be repeating in different ways, involves wrapping our custom element in a custom fragment. We do this because custom fragments have a number of facilities that will really take the IdProps custom element to the next level.

So let's go ahead and do this now. For this part, everything we do will be in the UI, no external development required.

This is already done for you if you followed the steps in the environment setup section and imported the fragments. In that case, just find the named fragment and edit it.

Start in the Fragments control panel and add a new Fragment Set to contain the custom fragments that we're going to be creating.

Then create a new Basic Fragment, call it the ID Configuration Prop Fragment.

On the Configuration tab, enter the following JSON in the text area:

{"fieldSets": [ {
  "fields": [{
    "dataType": "string",
    "defaultValue": "44",
    "label": "ID",
    "name": "idConfValue",
    "description": "ID to pass to the IdProps custom element",
    "type": "text",
    "typeOptions": {
      "placeholder": "Enter an ID"
    }
  }],
  "label": "ACET Config"
} ] }

This defines a single configuration element, but I could have multiple fields defined, one for each property that we want to capture a value for.

For reference on the configuration, you're going to want to visit Adding Configuration Options to Fragments and Fragment Configuration Types Reference.

On the Code tab, enter the following into the JavaScript section:

import "advanced-custom-element-techniques"

The import here is leveraging the JS Import map name used in the client-extension.yaml file of the custom element CX.

Enter the following into the HTML section:

<div class="fragment_acet_1">
  <acet-idprops 
    id="${configuration.idConfValue}"></acet-idprops>
</div>

The import we did in the JavaScript section allows us to use the tag in the HTML section. The syntax for the value used in the id attribute is FreeMarker to access the custom fragment configuration. After you set the value and you check the Preview section, you should see the Passed ID: 44 shown which is the default value in the configuration and it is being passed through successfully to the custom element.

Save the custom element, then edit the IdProps page created earlier. Drop the ID Configuration Prop Fragment on the page, then look to the General tab on the right side of the page. You'll now see a new section, ACET CONFIG and a new field, ID, which correspond to the values that we defined in the configuration for the fragment.

Using This Technique

So this is really it. By wrapping the custom element with a custom fragment that has defined configuration and leverages that configuration for the property value, the configuration itself has become much friendlier to the page creator.

When you check the Fragment Configuration Types Reference page and find the different types of configuration you can use for the custom fragment configuration, you can see that you get a number of different choices to constrain the values in different ways.

ACET #4: Use Custom Fragment FreeMarker Context

The next example is going to be very similar to the last one, but this one is going to take advantage of the custom fragment's use of FreeMarker context.

In the Fragment control panel, create a new Basic Fragment in the Advanced Custom Elements Fragment Set named Collection Item ID Fragment.

This one will have the same JavaScript import from the last section, but in the HTML section we're going to use the following:

[#assign infoItemId = "" ]

[#assign infoItemReference = (request.getAttribute("INFO_ITEM_REFERENCE"))! /]
[#if infoItemReference?has_content]
  [#if infoItemReference.infoItemIdentifier?has_content ]
    [#assign infoItemId = infoItemReference.infoItemIdentifier.classPK!"" ]
  [/#if]
[/#if]

<div class="fragment_acet_2">
  <acet-idprops id="${infoItemId}"></acet-idprops>
</div>

This script is taking advantage of the FreeMarker context and that Liferay will set a request attribute, INFO_ITEM_REFERENCE, for items within a Collection Display fragment.

Using This Technique

So for this one, we use the ACET Custom Object that we created and populated in the Environment Preparation section.

I then created a new page, added a Collection Display fragment that was configured to use the ACET Custom Objects Collection Provider.

Inside the Collection Display fragment, I dropped the Collection Item ID Fragment, and I was rewarded with the following:

So using the same custom element with a new custom fragment, and by leveraging info we have available in FreeMarker in the custom fragment, the idProps custom element is now given the ID for each of the objects that are part of the collection display. This way my custom element could fetch the object and use it in whatever way it needed to.

There are other FreeMarker context and services that you might want to invoke from other locations. If you want to know what is available depending upon where you drop the component, there's actually an OSGi module in the workspace that I copied from my friend Olaf (there's a README.md file there with all of the details) that can help show you all you might use.

A quick word about restClient... Since I have the object id, in my custom fragment I could use restClient to fetch the object and, within the <acet-idprops /> tag I could include a DOM representation of the object. Why would I want to do this? SEO, baby. The React custom element will be doing its own thing to render the content and the child elements of the <acet-idprops /> tag will be in the shadow DOM, but the crawlers won't look at it that way. They're just looking to index the content, not run all of your react applications. Note that this doesn't come without cost, so you should consider if you need the content to be indexed by the search engines or not. If you do, just keep this idea in your pocket as a way to solve any SEO dilemmas.

ACET #5: Take Advantage of Page Modes

Whether you know it or not, you can determine and use whether a Liferay page is in Edit mode or View mode. This can be helpful if you need to have a different sort of implementation/presentation for page editors vs that of the more typical view presentation.

To demonstrate this, you'll find a second custom element, <acet-mode-display /> which takes a Prop isEditMode. If you just drop this element on a page, you'll only ever see the Mode: View which is the default. That's because the element itself doesn't know current Liferay page state, for this we'll need to again wrap the custom element in a custom fragment, this time to use FreeMarker to get the current mode and pass it through to the custom element.

In the Advanced Custom Elements Fragment Set, create a new Basic Fragment named Edit Mode Display Fragment. Use the same import line in the JavaScript section we used before, and in the HTML section we're going to use:

[#assign editMode = (layoutMode == "view")?then('false', 'true') /]

<div class="fragment_acet_3">
	<acet-mode-display isEditMode="${editMode}"></acet-mode-display>
</div>

Using This Technique

So I created a new page and in edit mode I dropped the fragment on the page, and I could see that I was in Edit mode, and after publishing the page I could see that I was in View mode:

Note that there's other modes, i.e. in the fragment editor that's actually "preview" mode. For the sake of the custom element, I just treated it as though it was also edit mode. There may be other modes, but I wasn't really worried about those, I'm really only worried about the page editor and the typical view modes.

So the question you might be asking is, well, how is this useful?

We're going to be seeing a case of this soon, but one example I can think of is to include extra [meta]data in the display in edit mode vs view mode. For example, you might want to see the IDs of everything in edit mode so you can find the source objects/assets, but in view mode you wouldn't want to show them to others.

There are likely other examples. You'll know when you need to have alternate presentation in edit vs view modes, and when you get that case, you'll have this technique to help you implement it.

ACET #6: Leverage the Liferay JavaScript Object, Including Messaging

So this is really an interesting one I think...

First, if you're developing a custom element to run on Liferay, you do have access to the Liferay JavaScript object and all of the info and services that it provides including details about the current user, the ThemeDisplay details, etc.

To use it in my React code, I just created a Liferay.js file in the project and then set the contents as:

const Liferay = window.Liferay || {
  ThemeDisplay: {
    getCompanyGroupId: () => 20119,
    getPathThemeImages: () => '',
    getPortalURL: () => 'http://localhost:8080',
    getScopeGroupId: () => 20117,
    getSiteGroupId: () => 20117,
    isSignedIn: () => {
      return false;
    },
  },
  authToken: '',
  on: (_event, _callback) => {},
  fire: (_event, _data) => {},
};

export default Liferay;

So this is not the complete definition of the real Liferay JavaScript object, but that doesn't really matter. The first line setting Liferay to be the window.Liferay object will ensure that in the browser you'll get the complete object.

Everything else that is in here is really just to make your code happy. For example, I'm going to be using Liferay.fire() and Liferay.on(), and I've included them in the code above so there would be no apparent JavaScript build errors, and once in the actual Liferay environment they'll work correctly.

If there's a Liferay service or data that you need to use in your code, just throw it into this definition and at runtime you'll get the real Liferay version, not these placeholders.

Okay, so this is also about messaging... When building a React SPA, of course you have to do everything in your code... But in Liferay and in Standard Web Components, you're really doing more of microfrontends and UI composition based on the components. So instead of doing everything, you need to cooperate with other components, and sometimes this can mean communicating with them.

Liferay has a built-in mechanism that you can already take advantage of, the Liferay.on() (to register a listener for an event) and Liferay.fire() (to issue an event) methods.

I created two custom elements, <acet-liferay-on /> and <acet-liferay-fire /> which use this mechanism to talk to each other. The <acet-liferay-on /> has a count and will change the counter value every time the event is received. The <acet-liferay.fire /> has a button that, when clicked, will issue the event.

Using This Technique

So just create a page, then drop an Advanced Custom Element Techniques - Liferay On and an Advanced Custom Element Techniques - Liferay Fire client extensions on the page and publish it. As you click the button, the count will increment. Since the event both elements use is "ACET-Click", you can actually drop multiple On and Fire elements on the page and they will all work.

So this demonstrates a number of things...

First, the Liferay JavaScript object can be used in your component to access data and services from the Liferay context.

Second, specifically for the Liferay.on() and Liferay.fire() usage, these can be used to send messages between components on the page. Those messages can even include data. The example is two Standard Web Components that are created in React, but that really doesn't matter. There could be one in Angular and one in Vue.js, and they would still work just fine.

The only restriction is that Liferay.on() and Liferay.fire() only work within the page, you can't send messages across pages or context, and once a page refreshes, everything is reset.

ACET #7: Using Slots

Slots are a web standard, and since we're building Standard Web Components, they are available to take advantage of. Like Props, they're another way to pass data in the form of a DOM tree from the page into your component. As you'll soon see, this can be a powerful way to use a custom element yet still give necessary editorial control to the page creator.

On the React side, the SingleSlot.jsx file contains the simple component exposing the slot using the tag <acet-single-slot />. We'll be wrapping this in a custom fragment called Edit Field Slot Fragment, and the HTML section of the fragment will be:

<div class="fragment_acet_4">
  <acet-single-slot>
    <strong slot="singleSlotName" 
      data-lfr-editable-id="title" 
      data-lfr-editable-type="text">Enter Name Here!</strong>
  </acet-single-slot>
</div>

Using This Technique

Create a page and drop the Edit Field Slot Fragment on the page. You'll see the default text, Enter Name Here! which you can edit (just like any simple Paragraph fragment). Publish the page, and you'll see whatever you put in the fragment used. The important part to remember is that it is the custom element which is rendering the entered name!

This is a simple example, but consider something more complicated... The <slot /> in the custom element is a placeholder for DOM that the page creator would be managing, but it is still the custom element which renders that content as part of the component itself.

In this case the custom element only has a single slot and the custom fragment is only using a simple text value, but each of these aspects can be changed as necessary, allowing multiple slots and/or different types of inputs.

ACET #8: Using Drop Zones

This is an extension of the previous technique. The slot that we have defined in the custom element is still only a placeholder for a DOM tree that we will be populating in the wrapping fragment.

Except this time, instead of dropping a single editable field into the slot, we're going to add a Drop Zone instead.

The custom element is the same, but we'll create a new custom fragment called Drop Zone Slot Fragment, and the HTML section of the fragment will be:

<div class="fragment_acet_5">
  <acet-single-slot>
    <div slot="singleSlotName">
      <lfr-drop-zone></lfr-drop-zone>
    </div>
  </acet-single-slot>
</div>
Note: When using a Drop Zone like this, the Drop Zone itself cannot be tied to the slot. Instead, use another element (in the above case I used a <div />) and put the Drop Zone inside of it.

Using This Technique

Again we're going to create a page and then drop the new Drop Zone Slot Fragment on the page. We see immediately that there's a drop zone to contain additional elements and, in the screen grab below, you can see I've added a grid and dropped some elements inside of each column in the grid.

In the previous technique we were using individual slots to hold single elements, so the custom fragment was limiting what the page creator could use. Clearly there will be times where the custom element will require constrained use by the page creator, and the individual fields will help to enforce those constraints.

Using a Drop Zone, we are giving the page creator an unconstrained ability to define the contents. Imagine implementing a carousel using a custom element with a slot and the custom fragment with the Drop Zone, the page creator drops a grid into Drop Zone and individual cards in each grid column... The custom element can present these as a standard carousel, but the flexibility is there for the page creator to place whatever they want within the grid columns.

These two techniques both allow your page creators the ability to control what the custom element will be presenting, either using a constrained or unconstrained approach. Pick the one that is necessary for your requirements.

ACET #9: Mapping Fields

So this technique is actually just reusing the slots technique that was presented earlier.

There's a custom component in the custom element named MappingDisplay which renders a table that includes a name and a description, and those are both represented by slots.

Create a new custom fragment named Custom Object Display Fragment and use the following HTML:

[#assign infoItemId = "" ]

[#assign infoItemReference = (request.getAttribute("INFO_ITEM_REFERENCE"))! /]
[#if infoItemReference?has_content]
  [#if infoItemReference.infoItemIdentifier?has_content ]
    [#assign infoItemId = infoItemReference.infoItemIdentifier.classPK!"" ]
  [/#if]
[/#if]

<div class="fragment_acet_6">
  <acet-mapping-display id="${infoItemId}" >
    <strong slot="mapNameSlot" 
      data-lfr-editable-id="name" 
      data-lfr-editable-type="text">Enter Name Here!</strong>
    <strong slot="mapDescSlot" 
      data-lfr-editable-id="desc" 
      data-lfr-editable-type="text">Enter desc Here!</strong>
  </acet-mapping-display>
</div>

Here we're basically just using a new custom element, the <acet-mapping-display /> element, and we're declaring two slots, one for name and the other for the description.

Using This Technique

We're going to take advantage now of the ACET Custom Object that we have along with this new fragment.

Create a new content page and drop a Collection Display onto the page and configure it to use the ACET Custom Object Collection Provider. Drag the Custom Object Display Fragment into the Collection Item area.

If you're using the data from the Environment Preparation section, you'll have 3 entries in the display and, although the images may be different, the text is the same. Let's fix that next!

Click on the "Enter Name Here!" text to select the item, and on the right side change the field from Unmapped to the Name field. Repeat for the "Enter desc here!" mapped to the Description field.

Once this is completed, you should see the three items, but each one is displaying whatever name and description you used in the object entry!

ACET #10: Using Shadow DOM

The Shadow DOM is a web standard that enables encapsulation of DOM and CSS within a web component. This means that a component’s internal structure and styling are kept separate from the rest of the document, preventing styles from leaking in or out. When building standard web components in React, using the Shadow DOM allows you to safely isolate logic and presentation, ensuring consistent behavior no matter where the component is used. This encapsulation is especially powerful for complex components where control over DOM structure and styles is critical.

In scenarios where we use slots to pass data into a component like we've seen in the previous techniques, the Shadow DOM becomes essential. React doesn’t natively support slots the way the browser does for standard web components, so by using the Shadow DOM, we can access slotted content directly via the browser’s slot API. This gives us the flexibility to completely transform how the data is displayed within our React logic, while still allowing external markup to pass structured content into the component. Essentially, the Shadow DOM gives us the bridge between native slot behavior and React’s rendering capabilities. We'll be seeing an example of this in the finale coming up shortly.

One of the trade-offs of using the Shadow DOM is that styles from the main document (the Light DOM) do not apply inside it. Because of this, when your React standard web component renders, it will not have the Light DOM styles applied and, in Liferay, the component will not at all look the way you expect.

This problem is easy to resolve, but we'll take two approaches to resolve it...

Approach #1: Use the vite-plugin-css-injected-by-js Vite Plugin

So normally for your components, they'll have associated CSS files that you import with the component to go along with the rendering.

In finale custom element, for example, there is a MechanicCard.jsx file and it imports a MechanicCard.css file. This is standard practice for React components.

In a normal React application bundled by Vite, the CSS gets combined and served up in a single file and it is applied in the browser, specifically it is applied in the Light DOM. his works against us in the standard web components where we're leveraging the Shadow DOM.

The first change that we make is to add the vite-plugin-css-injected-by-js Vite plugin. This plugin modifies how CSS is handled in Vite by bundling the styles directly into the JavaScript output and injecting them into the document at runtime. This is particularly useful for web components using the Shadow DOM, where styles defined in external stylesheets wouldn’t normally apply. By injecting the CSS as part of the JavaScript, the styles can be programmatically inserted into the component’s shadow root, ensuring proper encapsulation and styling without relying on global styles or manual style management.

Start by installing the plugin using the command:

$ yarn add -D vite-plugin-css-injected-by-js

Next the vite.config.js file needs a modification to include the plugin:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';

// https://vite.dev/config/
export default defineConfig({
  base: '/o/advanced-custom-element-techniques',
  build: {
    outDir: './vite-build',
    rollupOptions: {
      output: {
        entryFileNames: 'assets/index.js',
        chunkFileNames: 'assets/chunk-[name].js',
        assetFileNames: ({ name }) => {
          if (name && name.endsWith('.css')) return 'assets/style.css'
          return 'assets/[name][extname]'
        },
      },
      external: [
        'react',
        'react-dom',
        /^(?!@clayui\/css)@clayui.*$/,
      ],
    }
  },
  plugins: [react(), cssInjectedByJsPlugin()],
})

Finally, we need to import each individual stylesheet with the ?inline argument in the main.jsx file:

import mechanicCardStyles from './featured/MechanicCard.css?inline';
import mechanicListStyles from './featured/MechanicsList.css?inline';
import appStyles from './featured/App.css?inline';

We have to explicitly import each stylesheet using the ?inline argument into a named var manually (the only pain point of this approach), but it will be used shortly to ensure the styles are applied in the Shadow DOM the way we need.

Approach #2: Create a setupShadowRootStyles() Function

The vite-plugin-css-injected-by-js plugin will have the individual stylesheets ready for us to apply, but there's still the CSS from the Light DOM that we'll want to include in the Shadow DOM, and we also want to avoid a flicker effect when the Shadow DOM is getting all of the styles assigned.

I've created a function, setupShadowRootStyles(), which you can copy and use in your own standard web components:

/**
 * Injects inline styles and cloned <link> styles into a shadowRoot.
 * Temporarily hides content to prevent FOUC, and restores visibility after styles
 * apply.
 *
 * @param {ShadowRoot} shadowRoot - The shadow DOM root to inject styles into
 * @param {string[]} inlineStyles - Array of CSS strings to inject as <style>
 */
export function setupShadowRootStyles(shadowRoot, inlineStyles = []) {
  // Ensure styles are hidden initially to avoid FOUC
  shadowRoot.host.style.visibility = 'hidden';

  // Inject <style> tags for local inline CSS
  inlineStyles.forEach((cssText) => {
    const styleTag = document.createElement('style');
    styleTag.textContent = cssText;
    shadowRoot.appendChild(styleTag);
  });

  // Clone <link rel="stylesheet"> from the main document
  const globalLinks = 
    document.querySelectorAll('link[rel="stylesheet"]');
  globalLinks.forEach((link) => {
    const clonedLink = link.cloneNode(true);
    shadowRoot.appendChild(clonedLink);
  });

  // Restore visibility on the next frame
  requestAnimationFrame(() => {
    shadowRoot.host.style.visibility = '';
  });
}

This function will apply the styles from the provided array and will also copy the global stylesheets to the Shadow DOM. The function also hides and restores the Shadow DOM while the styles are loading to avoid the Flash Of Unstyled Content aka FOUC.

Using the setupShadowRootStyles() Function

In the standard web component definition, the last thing we need to do is invoke the setupShadowRootStyles() function. We're going to do this in the constructor so we don't apply the styles numerous times. We'd invoke the function as follows:

class ACETFeaturedMechanicsElement extends HTMLElement {
  constructor() {
    super();
    this._root = null;
    this._shadow = this.attachShadow({ mode: 'open' });

    setupShadowRootStyles(this._shadow, 
      [appStyles, mechanicCardStyles, mechanicListStyles]);
  }

When you review the repo, you'll see that this technique is only being employed in the finale custom element. I didn't apply it to the others because they weren't about styling as much as they were just about demonstrating the technique (and styling wasn't necessary for that).

For actually putting this technique to use, well, we'll be using next in the finale...

Finale: Combining The Techniques

So a lot of techniques have been shared so far, but now we're going to combine a bunch of them to get the desired result...

The finale uses slots, drop zones, field mapping, Shadow DOM and styling, field mapping, as well as Edit Mode...

On the React custom element side, in the featured folder you'll find the FeaturedMechanicsList component as well as a MechanicCard component.

The FeaturedMechanicList uses a slot that will be bound to a Drop Zone in a custom fragment wrapper. When not in edit mode, it will display rows of MechanicCards, one for each mechanic from the collection we'll be adding later. There is some interesting code in the useEffect() method of the component which will basically process the DOM from the slot contents to extract values which will be later passed to the MechanicCard for rendering.

This is taking advantage of the Light and Shadow DOM so the slot can pass in details that we will extract and render completely differently than what we see in edit mode shortly.

There's also a custom fragment, Featured Mechanics Fragment, that contains for the HTML:

[#assign editMode = (layoutMode == "view")?then('false', 'true') /]

<div class="fragment_acet_8">
  <acet-featured-mechanics isEditMode="${editMode}">
    <div slot="dropZoneSlot"
[#if editMode != "true"]
         style="..."
         aria-hidden="true"
         hidden
[/#if]
>
      <lfr-drop-zone></lfr-drop-zone>
    </div>
  </acet-featured-mechanics>
</div>

This is taking advantage of edit mode, first by passing it to the custom element, but also for hiding the <div /> for the slot when not in edit mode. This ensures the slot contents will be visible in edit mode, but in view mode the div is offscreen so the content is available, just not visible to the end user.

It also has a Drop Zone as the content, this will give the page creator the ability to change the contents.

Putting This All Together

So we have the React custom element which handles the parsing and displaying of the contents, and we have the custom fragment with the drop zone, but now we need to put it all together.

Create a new blank content page called Featured Mechanics, then drop the Featured Mechanics Fragment into the page.

Drop a Collection Display fragment into the drop zone and configure it to use the ACET Custom Object Collection Provider. So far, this is kind of what we have done in the past...

Here's where we diverge, though. Drop three Paragraph fragments and one Image fragment in the Collection Item. Map the first paragraph to the ID from the ACET Custom Object, the second paragraph maps to the Name, and the third paragraph maps to the Description. For the Image fragment, map it to the Preview URL from the object.

Your display and fragment tree should look like the following:

It's important that your fragment hierarchy matches this layout because the React custom element expects this structure to do the proper content extraction.

Of course it is possible to make the React component not a rigid when it comes to structure. Using class names and better element selector queries is one way, but there are other ways to skin this cat. The important part is that the React custom element will be finding and grabbing the values from what we drop in here...

This is the edit view for the page, but the real magic happens when we publish the page and view it:

We're now displaying the contents, but React is rendering it all for us. It may be difficult to see in the image, but it actually is using a fisheye technique to blow up the card that the mouse is over.

Wow, right?

I know some are going to be saying "Wait a minute Dave, I could have done this same thing using [insert alternative methodology here]..." and my response would be "You're absolutely right!"

But I would point out that this is intended to be an example of how React can do anything we need it to with the data. Displaying in a row like this is one way, but we could have easily created a rolodex effect to spin through the cards, or a bunch of icons (created from the faces in the image) and clicking opened a dialog to show the full details, ...

Basically anything you can do in React you could do here, we're just getting the data dynamically populated into the React element without fetching it ourselves.

Conclusion

So I've just shared ten [of what I refer to as] advanced Custom Element techniques. Although they focus on React, they could all be implemented using Angular or Vue.js.

These techniques demonstrate how you can better integrate into Liferay, give page creators some flexibility in using your custom elements in a repeatable or templated fashion, and basically create elements that can be as awesome as your imagination allows!

Want to ask me questions about these?

Want to suggest an advanced technique not covered here?

Having trouble understanding or implementing your own custom elements?

Hit me up on the Liferay Community Slack or leave a comment below! Or better yet, join one of my Ask Me Anything sessions that happen every other Tuesday (Info is posted to the #community channel on the Liferay Community Slack).

Looking for the accompanying Github repository? It's right here:

Blogs

Hi Dave, thank you for sharing it! It will revolutionising how we can build our fragments to a different level. Liferay is becaming each one more incredible! Is there a possible to use slot in dynamic route? I don't think so but I will try anyway..