Custom components with Shadow DOM-Now available in Gameface!
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.
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:
1class GameInventory extends HTMLElement {2 constructor() {3 super();4 this.attachShadow({ mode: 'open' });5
6 this.state = {7 display: false8 };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.
1let slots = "";2
3for (let i = 0; i < InventoryItems.inventorySpace; i++) {4 slots += '<div class="inventory-slot"></div>';5}6
7const 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:
1connectedCallback() {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 theInventoryItems
model to the InventoryaddInventoryItem
- adds a single itemaddItemAt
- adds an item at a specific slotfindFreeSocketId
- finds the id of a socket that has no item in itisSocketFree
- checks if a socket has an item in ittoggle
- shows/hiddes the inventoryonTemplateLoaded
- Inits inventory logic
Click to view source
1/**2 * Adds the inventory items to the inventory.3 * InventoryItems is the data binding model registered in the global4 * namespace by Gameface.5*/6addInventoryItems() {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 slot16 * in the inventory.17 * @param {Object} item - the inventory item from the model (InventoryItems)18 * @param {string} itemId - the item's identifier19 * @param {number} [socketId=0] - the inventory socket's id into which the20 * item should added. The default is 0 - this will add it in the next free socket.21*/22addInventoryItem(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 the39 * item should added40*/41addItemAt(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*/54findFreeSocketId() {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 not63*/64isSocketFree(socketId) {65 return this.itemSlots[socketId] === undefined;66}67
68/**69 * Toggles the inventory instance.70*/71toggle() {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*/79onTemplateLoaded() {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 categorycreateModal
- Creates the modal element that will show the details of each inventory itemshowDetailsModal
- Creates the modal and appends it to the page with the correct item’s information
1class 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 information25 * @returns {HTMLElement} modal26 */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 container59 */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}69customElements.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 itsequipped
property totrue
.
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
.
48 collapsed lines
1const 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
30class 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 InventoryItem38 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}71customElements.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
1const 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
44class 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 button52 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
86customElements.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:
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:
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:
1body {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.