Custom components with Shadow DOM-Now available in Gameface!

news

1/20/2025

Martin Bozhilov

Learn how to create game inventory with the newly added Shadow DOM feature in Gameface!

Overview

With version 1.61, Gameface now has Shadow DOM support for custom elements , bringing a robust way to encapsulate styles and markup. Shadow DOM is a core feature of the modern Web Components standard, providing a mechanism to attach a hidden DOM tree to an element—effectively shielding the element’s structure, style, and behavior from the rest of the page. This encapsulation ensures that the component’s internal layout and styles are self-contained and do not leak or clash with other parts of the application.

Usage

To showcase the power and usability of the Shadow DOM, we will create a simple inventory menu with custom elements. In this inventory, players will be able to:

  • Open/close the inventory
  • View an item’s description
  • Equip or consume an item

The entire UI will be built with custom elements, using only vanilla JavaScript features.

Project setup

For this project, we are going to utilize data binding . We’ll also mock game data with a model containing all the necessary information for the inventory.

Click to reveal model

The inventory items will have an id, name and path to an image.

Additionally, we wil define a field with the number of available inventory slots.

model.js
1
(() => {
2
engine.createJSModel('InventoryItems', {
3
list: {
4
'icon_Axe_1_Small': {
5
typeId: 0,
6
name: 'Axe 1',
7
image: './images/icon_Axe_1_Small.png'
8
},
9
'icon_Axe_2_Small': {
10
typeId: 0,
11
name: 'Axe 2',
12
image: './images/icon_Axe_2_Small.png'
13
},
14
'icon_Axe_3_Small': {
15
typeId: 0,
16
name: 'Axe 3',
17
image: './images/icon_Axe_3_Small.png'
18
},
19
'icon_Book_Small': {
20
typeId: 1,
21
quantity: 8,
22
name: 'Book',
23
image: './images/icon_Book_Small.png'
24
},
25
'icon_Bracelet_Small': {
26
typeId: 0,
27
name: 'Bracelet',
28
image: './images/icon_Bracelet_Small.png'
29
},
30
'icon_Dagger_Small': {
31
typeId: 0,
32
name: 'Dagger',
33
image: './images/icon_Dagger_Small.png'
34
},
35
'icon_HealthPotion_Small': {
36
typeId: 1,
37
quantity: 10,
38
name: 'HealthPotion',
39
image: './images/icon_HealthPotion_Small.png'
40
},
41
'icon_PoisonVial_Small': {
42
typeId: 1,
43
quantity: 2,
44
name: 'PoisonVial',
45
image: './images/icon_PoisonVial_Small.png'
46
},
47
'icon_Ring1_Small': {
48
typeId: 0,
49
quantity: 5,
50
name: 'Ring 1',
51
image: './images/icon_Ring1_Small.png'
52
},
53
'icon_Ring2_Small': {
54
typeId: 1,
55
quantity: 5,
56
name: 'Ring 2',
57
image: './images/icon_Ring2_Small.png'
58
},
59
'icon_Scroll1_Small': {
60
typeId: 1,
61
quantity: 5,
62
name: 'Scroll 1',
63
image: './images/icon_Scroll1_Small.png'
64
},
65
'icon_Scroll2_Small': {
66
typeId: 1,
67
quantity: 5,
68
name: 'Scroll 2',
69
image: './images/icon_Scroll2_Small.png'
70
},
71
},
72
inventorySpace: 24,
73
});
74
})();

Inventory component

We’ll start with the main UI component: the inventory component.

In your project, create a folder named game-inventory and place a script.js file inside it. We’ll use a dedicated folder for each component to keep the project organized.

Let’s initialize the game-inventory component:

./game-inventory/script.js
1
class GameInventory extends HTMLElement {
2
constructor() {
3
super();
4
this.attachShadow({ mode: 'open' });
5
6
this.state = {
7
display: false
8
};
9
}
10
11
connectedCallback() {}
12
13
customElements.define("game-inventory", GameInventory);

To enable the Shadow DOM, we must attach the shadow root to our custom element.

Adding markup to the shadow root

There are several ways to add HTML to your custom elements. We’ll use a straightforward approach: keep the HTML and styles in the script as a string, then append them to the Shadow DOM after initialization.

Let’s create an inventoryTemplate variable and assign it the element’s markup.

./game-inventory/script.js
1
let slots = "";
2
3
for (let i = 0; i < InventoryItems.inventorySpace; i++) {
4
slots += '<div class="inventory-slot"></div>';
5
}
6
7
const inventoryTemplate = `
8
<style>
48 collapsed lines
9
.inventory-container {
10
width: 564px;
11
height: 473px;
12
background-image: url('./images/bg_MainMenu.png');
13
background-size: cover;
14
background-repeat: no-repeat no-repeat;
15
}
16
17
.inventory-slots {
18
width: 430px;
19
height: 330px;
20
display: flex;
21
flex-wrap: wrap;
22
position: relative;
23
top: 65px;
24
left: 65px;
25
}
26
27
.inventory-slot {
28
position: relative;
29
left: 0px;
30
width: 60px;
31
height: 60px;
32
margin-bottom: 10px;
33
margin-left: 10px;
34
background-image: url('./images/SkillTree_Slot.png');
35
background-size: cover;
36
background-repeat: no-repeat no-repeat;
37
}
38
39
.info {
40
margin-bottom: 10px;
41
margin-left: 10px;
42
position: relative;
43
top: 45px;
44
left: 84px;
45
color: #ffffff;
46
}
47
</style>
48
49
<div class="inventory-container">
50
<div class="info">
51
<span>Left click on an item to show details.</span>
52
<span>Right click on an item to use/equip.</span>
53
</div>
54
<div class="inventory-slots">
55
${slots}
56
</div>
57
</div>
58
`

Now in the connectedCallback method—which is called when the component is added to the DOM—we’ll append the template to the Shadow DOM:

./game-inventory/script.js
1
connectedCallback() {
2
this.shadowRoot.innerHTML = inventoryTemplate;
3
this.onTemplateLoaded();
4
}

We’ve also added a onTemplateLoaded method, where we will initialize the component’s logic after the HTML has been attached.

That’s it for the element’s markup! Thanks to the Shadow DOM, the component’s styles and markup are now encapsulated inside the game-inventory element.

Component Logic

To complete the inventory we’ll need to add the following methods.

  • addInventoryItems - adds the items from the InventoryItems model to the Inventory
  • addInventoryItem - adds a single item
  • addItemAt - adds an item at a specific slot
  • findFreeSocketId - finds the id of a socket that has no item in it
  • isSocketFree - checks if a socket has an item in it
  • toggle - shows/hiddes the inventory
  • onTemplateLoaded - Inits inventory logic
Click to view source
./game-inventory/script.js
1
/**
2
* Adds the inventory items to the inventory.
3
* InventoryItems is the data binding model registered in the global
4
* namespace by Gameface.
5
*/
6
addInventoryItems() {
7
const itemsIds = Object.keys(InventoryItems.list);
8
9
for (let itemId of itemsIds) {
10
this.addInventoryItem(InventoryItems.list[itemId], itemId);
11
}
12
}
13
14
/**
15
* Creates an inventory item instance and adds it into an available slot
16
* in the inventory.
17
* @param {Object} item - the inventory item from the model (InventoryItems)
18
* @param {string} itemId - the item's identifier
19
* @param {number} [socketId=0] - the inventory socket's id into which the
20
* item should added. The default is 0 - this will add it in the next free socket.
21
*/
22
addInventoryItem(item, itemId, socketId = 0) {
23
let WrappedComponent = 'inventory-consumable';
24
if (item.typeId === 0) WrappedComponent = 'inventory-weapon';
25
26
const inventoryItem = document.createElement('inventory-item');
27
inventoryItem.socket = socketId;
28
inventoryItem.itemid = itemId;
29
inventoryItem.imageurl = `{{InventoryItems.list.${itemId}.image}}`;
30
inventoryItem.description = item.name;
31
inventoryItem.WrappedComponent = WrappedComponent;
32
this.addItemAt(inventoryItem, socketId);
33
}
34
35
/**
36
* Adds an inventory item instance to a given inventory socket.
37
* @param {Object} item - the inventory item from the model (InventoryItems)
38
* @param {number} socketId - the inventory socket's id into which the
39
* item should added
40
*/
41
addItemAt(item, socketId) {
42
const itemSlotElements = this.shadowRoot.querySelectorAll('.inventory-slot');
43
44
if (!this.isSocketFree(socketId)) socketId = this.findFreeSocketId();
45
46
this.itemSlots[socketId] = socketId;
47
itemSlotElements[socketId].appendChild(item);
48
}
49
50
/**
51
* Finds the first free inventory socket.
52
* @returns {number} - the id of the socket.
53
*/
54
findFreeSocketId() {
55
for (let i = 0; i < this.itemSlots.length; i++) {
56
if (this.itemSlots[i] === undefined) return i;
57
}
58
}
59
60
/**
61
* Checks if an inventory socket with a given id is free.
62
* @returns {boolean} - true if it's free, false if it's not
63
*/
64
isSocketFree(socketId) {
65
return this.itemSlots[socketId] === undefined;
66
}
67
68
/**
69
* Toggles the inventory instance.
70
*/
71
toggle() {
72
this.state.display = !this.state.display;
73
this.classList.toggle('hidden', !this.state.display);
74
}
75
76
/**
77
* Called when the component's template was loaded.
78
*/
79
onTemplateLoaded() {
80
this.itemSlots = new Array(slots.length);
81
this.addInventoryItems();
82
this.classList.toggle('hidden', !this.state.display);
83
}

The rest of the components

We’ll create the other custom elements in this UI using the same approach: defining a template string and appending it to the element’s Shadow DOM (where applicable).

Inventory item

The inventory item will have a details panel that opens on click.

Inventory items will of two types - consumable and weapon. Where the consumable item will have quantity and the weapon - won’t. We’ll be making new components for the weapon and consumable that will be wrapped in an inventory-item in order to share functionality. The inventory-item will populate itself with either a weapon or a consumable depending on the type of the current item.

Note: The inventory-item component doesn’t contain its own markup since it acts solely as a functionality wrapper, so we won’t use the Shadow DOM there.

Click to reveal component’s implementation

We will define the following methods:

  • setup - Will collect the item’s info and create the child component depending on it’s category
  • createModal - Creates the modal element that will show the details of each inventory item
  • showDetailsModal - Creates the modal and appends it to the page with the correct item’s information
./inventory-item/script.js
1
class InventoryItem extends HTMLElement {
2
constructor() {
3
super();
4
this.onClick = () => this.showDetailsModal();
5
}
6
7
connectedCallback() {
8
this.classList.add('inventory-item');
9
this.details = document.getElementById("details-container");
10
11
this.setup();
12
}
13
14
setup() {
15
const wrappedComponent = document.createElement(this.WrappedComponent);
16
wrappedComponent.itemid = this.itemid;
17
wrappedComponent.imageurl = this.imageurl;
18
wrappedComponent.description = this.description;
19
wrappedComponent.onClick = this.onClick;
20
this.appendChild(wrappedComponent);
21
}
22
23
/**
24
* Creates modal containing the item's information
25
* @returns {HTMLElement} modal
26
*/
27
createModal() {
28
const modal = document.createElement('div');
29
modal.className = 'modal';
30
31
const header = document.createElement('div');
32
header.className = 'modal-header';
33
header.textContent = this.description;
34
modal.appendChild(header);
35
36
const content = document.createElement('div');
37
content.className = 'modal-content';
38
modal.appendChild(content);
39
40
const imageItem = document.createElement('div');
41
imageItem.style.backgroundImage = `url(${InventoryItems.list[this.itemid].image})`;
42
imageItem.className = 'info-image';
43
content.appendChild(imageItem);
44
45
const itemDescription = document.createElement('div');
46
itemDescription.textContent = 'Item description';
47
content.appendChild(itemDescription);
48
49
const closeBtn = document.createElement('div');
50
closeBtn.className = 'close-x';
51
closeBtn.addEventListener('click', () => modal.classList.remove('visible'))
52
modal.appendChild(closeBtn)
53
54
return modal;
55
}
56
57
/**
58
* Creates and appends a modal element to the detail's container
59
*/
60
showDetailsModal() {
61
const modal = this.createModal();
62
63
const detailsContainer = document.getElementById('details-container');
64
detailsContainer.innerHTML = '';
65
detailsContainer.appendChild(modal);
66
modal.classList.add('visible');
67
}
68
}
69
customElements.define('inventory-item', InventoryItem);

Inventory weapon

The weapon component is going to have logic for setting up the content - the image and the description and a method for equipping. We will set up the content and attach the event listeners in the connectedCallback. Apart from that we’ll also need to call engine.synchronizeModels() to make sure that the data binding attributes are updated.

We will define the following methods:

  • setupContent - Sets the html content of the weapon item and sets the data-binding attributes.
  • equip - Equips the weapon by setting its equipped property to true.

Remember, because elements in the Shadow DOM are scoped to that shadow root, we must query them with this.shadowRoot.querySelector instead of the global document.querySelector.

./inventory-weapon/script.js
48 collapsed lines
1
const weaponTemplate = `
2
<style>
3
.weapon {
4
position: relative;
5
left: 0px;
6
width: 60px;
7
height: 60px;
8
}
9
10
.image {
11
position: relative;
12
top: 4px;
13
left: 0px;
14
width: 60px;
15
height: 60px;
16
background-size: contain;
17
background-repeat: no-repeat;
18
}
19
20
.disabled {
21
opacity: 0.5;
22
}
23
</style>
24
<div class="weapon">
25
<div class="image"></div>
26
<div class="description"></div>
27
</div>
28
`;
29
30
class InventoryWeapon extends HTMLElement {
31
constructor() {
32
super();
33
this.attachShadow({ mode: 'open' });
34
35
this.onClickBound = (e) => {
36
if (e.button === 2) return this.equip();
37
// on click is assigned in InventoryItem
38
this.onClick();
39
};
40
}
41
42
connectedCallback() {
43
this.shadowRoot.innerHTML = weaponTemplate;
44
this.setupContent();
45
this.addEventListener('mousedown', this.onClickBound);
46
engine.synchronizeModels();
47
}
48
49
/**
50
* Sets the html content of the consumable item and sets the data binding attributes.
51
*/
52
setupContent() {
53
this.classList.add('inventory-weapon');
54
this.shadowRoot.querySelector('.image')
55
.setAttribute('data-bind-style-background-image-url', this.imageurl);
56
this.shadowRoot.querySelector('.weapon')
57
.setAttribute('data-bind-class-toggle', `disabled:{{InventoryItems.list.${this.itemid}.equipped}} == true`);
58
}
12 collapsed lines
59
60
/**
61
* Equips the weapon by setting its equipped property to true.
62
*/
63
equip() {
64
if (InventoryItems.list[this.itemid].equipped) return;
65
66
InventoryItems.list[this.itemid].equipped = true;
67
engine.updateWholeModel(InventoryItems);
68
engine.synchronizeModels();
69
}
70
}
71
customElements.define('inventory-weapon', InventoryWeapon);

Inventory consumable

Similar to the weapon component, the consumable component is also an inventory item, but the right-click interaction uses one of the consumables instead of equipping it. Its template differs slightly by including a quantity badge.

We will define the following methods:

  • setupContent - Sets the HTML content of the consumable item and sets the data binding attributes.
  • use - Decreases the consumable’s quantity by 1 when used.
Click to reveal component’s implementation
./inventory-consumable/script.js
1
const consumableTemplate = `
2
<style>
3
.consumable {
4
position: relative;
5
left: 0px;
6
width: 60px;
7
height: 60px;
8
z-index: 1;
9
}
10
11
.quantity {
12
z-index: 2;
13
width: 15px;
14
border-radius: 10px;
15
background-color: greenyellow;
16
position: absolute;
17
right: 0px;
18
bottom: 0px;
19
font-size: 10px;
20
line-height: 15px;
21
text-align: center;
22
}
23
24
.image {
25
position: relative;
26
top: 4px;
27
left: 0px;
28
width: 60px;
29
height: 60px;
30
background-size: contain;
31
background-repeat: no-repeat;
32
}
33
34
.disabled {
35
opacity: 0.5;
36
}
37
</style>
38
<div class="consumable">
39
<div class="image"></div>
40
<div class="quantity"></div>
41
</div>
42
`;
43
44
class InventoryConsumable extends HTMLElement {
45
constructor() {
46
super();
47
this.attachShadow({ mode: 'open' });
48
49
this.onClickBound = (e) => {
50
if (InventoryItems.list[this.itemid].quantity === 0) return;
51
// right mouse button
52
if (e.button === 2) return this.use();
53
this.onClick();
54
};
55
}
56
57
connectedCallback() {
58
this.shadowRoot.innerHTML = consumableTemplate;
59
this.setupContent();
60
this.addEventListener('mousedown', this.onClickBound);
61
engine.synchronizeModels();
62
}
63
64
/**
65
* Sets the html content of the consumable item and sets the data binding attributes.
66
*/
67
setupContent() {
68
this.classList.add('inventory-consumable');
69
70
this.shadowRoot.querySelector('.image').setAttribute('data-bind-style-background-image-url', this.imageurl);
71
this.shadowRoot.querySelector('.consumable').setAttribute('data-bind-class-toggle', `disabled:{{InventoryItems.list.${this.itemid}.quantity}} == 0`);
72
73
this.shadowRoot.querySelector('.quantity').setAttribute('data-bind-value', `{{InventoryItems.list.${this.itemid}.quantity}}`);
74
}
75
76
/**
77
* Uses one of the consumable items by decreasing its quantity by 1.
78
*/
79
use() {
80
InventoryItems.list[this.itemid].quantity -= 1;
81
engine.updateWholeModel(InventoryItems);
82
engine.synchronizeModels();
83
}
84
}
85
86
customElements.define('inventory-consumable', InventoryConsumable);

Bringing it all together

After setting up our custom components, the last step is to bring everything together.

First, create an index.html file and import the cohtml.js file, the previously created model, and all your custom elements:

index.html
1
<!DOCTYPE html>
2
<html lang="en">
3
4
<head>
5
<meta charset="UTF-8">
6
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
<title>Game inventory</title>
8
<link rel="stylesheet" href="style.css">
9
<script src="./cohtml.js"></script>
10
<script src="./model.js"></script>
11
<script src="./game-inventory/script.js"></script>
12
<script src="./inventory-weapon/script.js"></script>
13
<script src="./inventory-consumable/script.js"></script>
14
<script src="./inventory-item/script.js"></script>
15
</head>
16
17
<body>
18
</body>
19
20
</html>

Next, let’s create a button to toggle the inventory, as well as add our game-inventory element—the main entry point for this project’s logic:

index.html
1
<body>
2
<div id="toggle_inventory" class="button">Open Inventory</div>
3
<div class="ui">
4
<div id="inventory-wrapper">
5
<game-inventory></game-inventory>
6
</div>
7
<div id="details-container"></div>
8
</div>
9
10
<script>
11
const toggleInventoryBtn = document.getElementById('toggle_inventory');
12
13
toggleInventoryBtn.addEventListener('click', () => {
14
const inventory = document.querySelector('game-inventory');
15
inventory.toggle();
16
});
17
</script>
18
</body>

Finally, add some basic styles for the toggle button and the modal element:

styles.css
1
body {
2
margin: 0;
3
background-color: gray;
4
}
5
6
.hidden {
7
display: none;
8
9
}
77 collapsed lines
10
.button {
11
color: #ffffff;
12
width: 261px;
13
height: 53px;
14
cursor: pointer;
15
background-size: contain;
16
background-repeat: no-repeat no-repeat;
17
background-image: url(./images/btn_MainMenu_normal.png);
18
text-align: center;
19
font-size: 17px;
20
line-height: 46px;
21
}
22
23
.button:hover {
24
background-image: url(./images/btn_MainMenu_active.png);
25
}
26
27
.ui {
28
display: flex;
29
flex-direction: row;
30
}
31
32
#details-container {
33
margin-left: 20px;
34
}
35
36
/* ---------------------------------- */
37
.modal {
38
background-image: url('./images/bg_MainMenu.png');
39
background-size: contain;
40
background-repeat: no-repeat;
41
padding: 25px;
42
margin-top: 10px;
43
display: none;
44
flex-direction: column;
45
align-items: center;
46
position: relative;
47
color: #ffffff;
48
width: 300px;
49
height: 250px;
50
}
51
52
.modal.visible {
53
display: flex;
54
}
55
56
.modal-header {
57
font-weight: bold;
58
margin-bottom: 15px;
59
}
60
61
.info-image {
62
width: 120px;
63
height: 120px;
64
background-size: contain;
65
background-repeat: no-repeat;
66
background-position: center;
67
margin-bottom: 10px;
68
}
69
.close-x {
70
position: absolute;
71
top: 0;
72
right: 10px;
73
width: 35px;
74
height: 35px;
75
cursor: pointer;
76
background-image: url('./images/btn_Close.png');
77
background-size: contain;
78
background-repeat: no-repeat;
79
border: none;
80
background-color: transparent;
81
}
82
83
.close-x:hover {
84
background-color: transparent;
85
background-image: url('./images/btn_Close2.png');
86
}

With that, our custom-elements-based game inventory UI is finished!

In conclusion

By leveraging custom elements and the Shadow DOM, building modular UI components becomes both simpler and more robust. The level of encapsulation and reusability custom elements provide means you can confidently scale your project and reuse all elements without worrying about conflicting styles or tangled logic.

Sample location

You can find the complete sample source within the ${Gameface package}/Samples/uiresources/ComponentsWithShadowDOM directory.

On this page