Custom UI - "Headless UI"

Introduction

The Epigraph Configurator ships with a default UI that covers most use cases out of the box. However, if you need full control over the look and feel of your product configurator, you can run the component in headless mode. This disables all built-in UI elements and exposes the Core API, letting you build your own interface from scratch while the configurator handles 3D rendering, scene management, and AR.

Headless mode is ideal when you need to:

  • Match a specific brand or design system
  • Build a non-standard layout (e.g. sidebar panels, floating menus)
  • Integrate the configurator into an existing application shell

Prerequisites

  • A working Epigraph Configurator setup on your page. If you haven't done this yet, follow the Adding Configurator to Your Page guide first.
  • A experience-id provided by Epigraph. This key is domain-locked, so request a separate key if you need to whitelist additional domains (e.g. staging).
  • Familiarity with the Configurator Events system.

Step 1: Setting Up Headless Mode

Add the disable-ui attribute to the <epigraph-configurator> element. This tells the component to render only the 3D canvas, with no built-in buttons, menus, or overlays.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Custom Configurator UI</title>
  <script src="https://cdn.jsdelivr.net/npm/@epigraph/configurator"></script>
</head>
<body>
  <div class="epg-configurator-container">
    <epigraph-configurator
      id="wcEpigraphConfigurator"
      experience-id="experience-id-provided-by-epigraph"
      disable-ui>
    </epigraph-configurator>
  </div>
</body>
</html>

Note: Replace YOUR_ACCESS_KEY with the key provided by Epigraph.

Step 2: Accessing the Core API

The Core API is your main interface for interacting with the configurator programmatically. It becomes available asynchronously, so you must listen for the coreApi:ready event before calling any methods.

const configurator = document.getElementById("wcEpigraphConfigurator");

configurator.addEventListener("coreApi:ready", function (event) {
  console.log("Core API ready.");
  const coreApi = configurator.api.core;

  // You can now call coreApi methods
});

configurator.addEventListener("coreApi:failed", function (event) {
  console.error("Core API failed to initialize.", event);

  // Show a user-friendly error message.
  // This can happen due to network issues, invalid access keys,
  // or unsupported hardware.
});

The API is accessed via configurator.api.core. Store a reference to it for use throughout your application.

Step 3: Loading a Pre-Configured Scene

If you store configurations with Epigraph, each one receives a unique ID. Pass that ID to loadSceneFromConfigurationID() to load it into the scene:

const configId = "mWVXMSG5o1TwaqTTNd16";
coreApi.loadSceneFromConfigurationID(configId);

This fetches the configuration from Epigraph's database and loads it into the scene. You can wire this up to a button:

<button data-config-id="mWVXMSG5o1TwaqTTNd16" id="loadConfig1">
  Load Configuration 1
</button>
const loadButton = document.getElementById("loadConfig1");
loadButton.addEventListener("click", () => {
  const configId = loadButton.dataset.configId;
  coreApi.loadSceneFromConfigurationID(configId);
});

Step 4: Switching Global Variants

Global variants let users change the material or finish of an entire category at once (e.g. all "wood" items switch to "walnut").

Call switchGlobalVariant(category, variant) with the category name and the desired variant:

coreApi.switchGlobalVariant("wood", "birch");

Here's how to wire up a set of variant buttons:

<button data-look-category="wood" data-look-variant="birch">Birch</button>
<button data-look-category="wood" data-look-variant="oak">Oak</button>
<button data-look-category="wood" data-look-variant="walnut">Walnut</button>

<button data-look-category="hardware" data-look-variant="black">Black Hardware</button>
<button data-look-category="hardware" data-look-variant="white">White Hardware</button>
const variantButtons = document.querySelectorAll("[data-look-category]");

variantButtons.forEach((button) => {
  button.addEventListener("click", () => {
    const category = button.dataset.lookCategory;
    const variant = button.dataset.lookVariant;
    coreApi.switchGlobalVariant(category, variant);
  });
});

Step 5: View In Your Space (AR)

The configurator can launch an AR experience, allowing users to place the configured product in their physical environment.

const arButton = document.getElementById("viewInYourSpace");
arButton.addEventListener("click", () => {
  coreApi.viewInYourSpace();
});

Production tip: Use coreApi.canLaunchAR() to check device/browser support before showing the AR button. This avoids presenting a non-functional button on unsupported devices.

Step 6: Styling the Configurator

Hiding the Configurator Until Ready

When the page first loads, the web component has not yet been defined and the 3D canvas is not ready. Use the :defined pseudo-class to transition from a loading state to the rendered configurator:

/* Hidden by default */
#wcEpigraphConfigurator {
  width: 100%;
  height: 100%;
  position: relative;
  opacity: 0;
}

/* Visible once the web component is registered */
epigraph-configurator:defined {
  opacity: 1 !important;
}

Customizing the Loading Spinner

The built-in loading spinner is exposed as a CSS shadow part. Override its styles with the ::part() pseudo-element:

epigraph-configurator::part(circular-loader) {
  border: 8px solid #0000001a;
  border-top: 8px solid #000000;
  border-radius: 50%;
  width: 140px;
  height: 140px;
}

Step 7: Custom Context Menu

When a user clicks an item in the scene, the configurator fires a context menu event. You can intercept this to show your own menu with variant options for the selected item.

Note: Ask the Epigraph team to disable the default context menu for your project to avoid showing two menus.

HTML Structure

Position the context menu container inside the configurator wrapper so it overlays the canvas:

<div class="epg-configurator-container">
  <!-- Context menu overlays the canvas -->
  <div id="contextMenuContainer" class="context-menu-container">
    <div class="context-menu-info-container">
      <div id="contextMenuTargetItem">ITEM NAME</div>
      <button id="contextMenuCloseButton">X</button>
    </div>
    <div id="contextMenuThumbnailsContainer" class="context-menu-thumbnails-container">
      <!-- Variant thumbnails are populated dynamically -->
    </div>
  </div>

  <epigraph-configurator
    id="wcEpigraphConfigurator"
    experience-id="YOUR_EXPERIENCE_ID"
    disable-ui>
  </epigraph-configurator>
</div>

Subscribing to Context Menu Events

Use the type-safe event accessors on the Core API's EVENTS object:

function setupCustomContextMenu() {
  const contextMenu = document.getElementById("contextMenuContainer");

  configurator.addEventListener(
    coreApi.EVENTS.UI.ContextMenu_Show,
    (event) => {
      clearContextMenuThumbnails(contextMenu);
      populateContextMenu(contextMenu, event);
      contextMenu.classList.add("show-context-menu");
    }
  );

  configurator.addEventListener(
    coreApi.EVENTS.UI.ContextMenu_Hide,
    () => {
      contextMenu.classList.remove("show-context-menu");
    }
  );

  const closeButton = contextMenu.querySelector("#contextMenuCloseButton");
  closeButton.addEventListener("click", () => {
    coreApi.closeContextMenu();
  });
}

Populating Variant Options

The ContextMenu_Show event carries data about the clicked item, including its available looks and GUID. Use overrideItemMaterialByGuid(guid, variantName) to apply a variant.

function clearContextMenuThumbnails(contextMenu) {
  contextMenu.querySelectorAll(".context-menu-thumbnail").forEach((el) => el.remove());
}

function populateContextMenu(contextMenu, event) {
  const details = event.data.primary;
  const looks = [...details.data.itemData.looks].sort();
  const container = contextMenu.querySelector("#contextMenuThumbnailsContainer");

  contextMenu.querySelector("#contextMenuTargetItem").textContent = details.name;

  looks.forEach((variantName) => {
    const thumbnail = document.createElement("div");
    thumbnail.classList.add("context-menu-thumbnail");
    thumbnail.textContent = variantName;

    thumbnail.addEventListener("click", () => {
      coreApi.overrideItemMaterialByGuid(details.guid, variantName);
    });

    container.appendChild(thumbnail);
  });
}

Event Data Shape

The ContextMenu_Show event provides:

PathTypeDescription
event.data.primaryobjectThe primary item that was clicked
.namestringDisplay name of the item
.guidstringUnique identifier for this item instance
.data.itemData.looksstring[]Available variant/look names for the item

Context Menu Styling

The context menu uses a CSS transition to slide in:

.context-menu-container {
  max-width: 400px;
  height: 150px;
  background-color: gray;
  position: absolute;
  z-index: 3;
  opacity: 0;
  transition: 0.5s;
  display: flex;
  gap: 10px;
  flex-direction: column;
  overflow: hidden;
  padding: 10px;
  box-sizing: border-box;
  transform: translateX(-50%);
}

.context-menu-container.show-context-menu {
  opacity: 1;
  transform: translateX(0%);
}

.context-menu-info-container {
  width: 100%;
  height: 20%;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 20px;
}

.context-menu-thumbnails-container {
  width: 100%;
  height: 80%;
  display: flex;
  justify-content: flex-start;
  align-items: center;
  overflow-x: auto;
  overflow-y: hidden;
}

.context-menu-thumbnail {
  min-width: 100px;
  min-height: 100px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 5px;
  box-sizing: border-box;
}

.context-menu-thumbnail:hover {
  background-color: rgba(0, 0, 0, 0.4);
}

Complete Example

A full, self-contained page that demonstrates all the concepts above:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Custom Configurator UI</title>
  <script src="https://cdn.jsdelivr.net/npm/@epigraph/configurator"></script>
  <style>
    html, body {
      margin: 0;
      width: 100%;
      height: 100%;
      display: flex;
      justify-content: center;
      align-items: center;
      overflow: hidden;
    }

    .main-container {
      width: 90vw;
      height: 90vh;
      display: flex;
      flex-direction: row;
    }

    .epg-configurator-container {
      width: 100%;
      height: 100%;
      position: relative;
    }

    #wcEpigraphConfigurator {
      width: 100%;
      height: 100%;
      position: relative;
      opacity: 0;
    }

    epigraph-configurator:defined {
      opacity: 1 !important;
    }

    epigraph-configurator::part(circular-loader) {
      border: 8px solid #0000001a;
      border-top: 8px solid #000000;
      border-radius: 50%;
      width: 140px;
      height: 140px;
    }

    .sidebar {
      display: flex;
      flex-direction: column;
      min-width: 200px;
      background-color: #c5c5c5;
      padding: 20px;
      box-sizing: border-box;
      overflow-y: auto;
      font-family: sans-serif;
    }

    .category-title {
      font-size: 1.25rem;
      font-weight: 700;
      margin-top: 1rem;
    }

    .button-group {
      display: flex;
      flex-wrap: wrap;
      gap: 0.5rem;
      margin-top: 0.5rem;
    }

    .btn {
      padding: 0.5rem 1rem;
      cursor: pointer;
      border-radius: 8px;
      border: 1px solid #999;
      background: #fff;
      transition: background-color 0.3s, color 0.3s;
    }

    .btn:hover {
      background-color: #000;
      color: #fff;
    }

    /* Context Menu */
    .context-menu-container {
      max-width: 400px;
      height: 150px;
      background-color: gray;
      position: absolute;
      z-index: 3;
      opacity: 0;
      transition: 0.5s;
      display: flex;
      gap: 10px;
      flex-direction: column;
      overflow: hidden;
      padding: 10px;
      box-sizing: border-box;
      transform: translateX(-50%);
    }

    .context-menu-container.show-context-menu {
      opacity: 1;
      transform: translateX(0%);
    }

    .context-menu-info-container {
      width: 100%;
      height: 20%;
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 20px;
    }

    .context-menu-thumbnails-container {
      width: 100%;
      height: 80%;
      display: flex;
      justify-content: flex-start;
      align-items: center;
      overflow-x: auto;
      overflow-y: hidden;
    }

    .context-menu-thumbnail {
      min-width: 100px;
      min-height: 100px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      padding: 5px;
      box-sizing: border-box;
      color: #fff;
    }

    .context-menu-thumbnail:hover {
      background-color: rgba(0, 0, 0, 0.4);
    }
  </style>
</head>
<body>
  <div class="main-container">
    <div class="epg-configurator-container">
      <!-- Custom Context Menu -->
      <div id="contextMenuContainer" class="context-menu-container">
        <div class="context-menu-info-container">
          <div id="contextMenuTargetItem">ITEM NAME</div>
          <button id="contextMenuCloseButton">X</button>
        </div>
        <div id="contextMenuThumbnailsContainer" class="context-menu-thumbnails-container">
        </div>
      </div>

      <epigraph-configurator
        id="wcEpigraphConfigurator"
        experience-id="experience-id-provided-by-epigraph"
        disable-ui>
      </epigraph-configurator>
    </div>

    <div class="sidebar">
      <div class="category-title">Saved Configurations</div>
      <div class="button-group">
        <button class="btn" data-config-id="mWVXMSG5o1TwaqTTNd16" id="loadConfig1">
          Load Configuration
        </button>
      </div>

      <div class="category-title">Wood Finish</div>
      <div class="button-group">
        <button class="btn" data-look-category="wood" data-look-variant="birch">Birch</button>
        <button class="btn" data-look-category="wood" data-look-variant="oak">Oak</button>
        <button class="btn" data-look-category="wood" data-look-variant="walnut">Walnut</button>
      </div>

      <div class="category-title">Hardware Finish</div>
      <div class="button-group">
        <button class="btn" data-look-category="hardware" data-look-variant="black">Black</button>
        <button class="btn" data-look-category="hardware" data-look-variant="white">White</button>
      </div>

      <div class="category-title">Additional Features</div>
      <div class="button-group">
        <button class="btn" id="viewInYourSpace">View In Your Space</button>
      </div>
    </div>
  </div>

  <script>
    (function main() {
      const configurator = document.getElementById("wcEpigraphConfigurator");
      let coreApi;

      configurator.addEventListener("coreApi:ready", function () {
        console.log("Core API ready.");
        coreApi = configurator.api.core;

        // --- Saved Configuration ---
        const loadConfig1 = document.getElementById("loadConfig1");
        loadConfig1.addEventListener("click", () => {
          coreApi.loadSceneFromConfigurationID(loadConfig1.dataset.configId);
        });

        // --- Global Variant Buttons ---
        const variantButtons = document.querySelectorAll("[data-look-category]");
        variantButtons.forEach((button) => {
          button.addEventListener("click", () => {
            coreApi.switchGlobalVariant(
              button.dataset.lookCategory,
              button.dataset.lookVariant
            );
          });
        });

        // --- View In Your Space ---
        const arButton = document.getElementById("viewInYourSpace");
        arButton.addEventListener("click", () => {
          coreApi.viewInYourSpace();
        });

        // --- Custom Context Menu ---
        setupCustomContextMenu();
      });

      configurator.addEventListener("coreApi:failed", function (event) {
        console.error("Core API failed to initialize.", event);
      });

      function setupCustomContextMenu() {
        const contextMenu = document.getElementById("contextMenuContainer");

        configurator.addEventListener(
          coreApi.EVENTS.UI.ContextMenu_Show,
          (event) => {
            contextMenu.querySelectorAll(".context-menu-thumbnail").forEach((el) => el.remove());
            populateContextMenu(contextMenu, event);
            contextMenu.classList.add("show-context-menu");
          }
        );

        configurator.addEventListener(
          coreApi.EVENTS.UI.ContextMenu_Hide,
          () => {
            contextMenu.classList.remove("show-context-menu");
          }
        );

        contextMenu.querySelector("#contextMenuCloseButton").addEventListener("click", () => {
          coreApi.closeContextMenu();
        });
      }

      function populateContextMenu(contextMenu, event) {
        const details = event.data.primary;
        const looks = [...details.data.itemData.looks].sort();
        const container = contextMenu.querySelector("#contextMenuThumbnailsContainer");

        contextMenu.querySelector("#contextMenuTargetItem").textContent = details.name;

        looks.forEach((variantName) => {
          const thumbnail = document.createElement("div");
          thumbnail.classList.add("context-menu-thumbnail");
          thumbnail.textContent = variantName;

          thumbnail.addEventListener("click", () => {
            coreApi.overrideItemMaterialByGuid(details.guid, variantName);
          });

          container.appendChild(thumbnail);
        });
      }
    })();
  </script>
</body>
</html>

API Reference Summary

Method / PropertyDescription
configurator.api.coreReference to the Core API singleton
loadSceneFromConfigurationID(id)Loads a saved configuration by its Epigraph-hosted ID
switchGlobalVariant(category, variant)Changes the active variant for an entire material category
viewInYourSpace()Launches the AR experience on supported devices
canLaunchAR()Returns whether the current device supports AR
overrideItemMaterialByGuid(guid, variant)Changes the material/look of a specific item instance
closeContextMenu()Programmatically closes the context menu
EventDescription
coreApi:readyFired when the Core API has initialized successfully
coreApi:failedFired when the Core API fails to initialize
EVENTS.UI.ContextMenu_ShowAn item was clicked in the scene; includes item data and available looks
EVENTS.UI.ContextMenu_HideThe context menu should be dismissed
CSS SelectorDescription
epigraph-configurator:definedMatches once the web component is registered
epigraph-configurator::part(circular-loader)Targets the built-in loading spinner

What's Next

Now that you have a custom UI controlling the configurator, see Implementing Drag and Drop to let users drag products from a sidebar into the 3D scene.

For more API methods and events, refer to the full API reference and Core Events documentation.