Custom UI - Drag and Drop

Introduction

Drag and drop lets users build product configurations by dragging thumbnails from a sidebar and dropping them onto the 3D canvas. When an item enters the canvas, the configurator takes over and shows valid attachment points. When the user releases, the item snaps into place.

This guide covers:

  • Populating a product sidebar from the API's product data
  • Implementing mouse and touch drag with a floating preview image
  • Handing off to the configurator when the dragged item enters the canvas

Prerequisites

  • A working headless configurator setup. If you haven't done this yet, follow the Building a Custom UI guide first.
  • Familiarity with the Core Events system, in particular the type-safe EVENTS.UI.* and EVENTS.SCENE.* accessors.

Step 1: Populating Product Thumbnails

Once the Core API is ready, access the productData property to get all available products. Each product contains look variants with thumbnail URLs.

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

configurator.addEventListener("coreApi:ready", function () {
  coreApi = configurator.api.core;
  populateProductThumbnails(coreApi.productData);
});

The productData object is keyed by SKU ID. Each entry includes a defaultVariant name and a lookDetails map containing thumbnail paths per variant.

Important: productData includes a synthetic "Root" entry used internally. Remove it before iterating.

function populateProductThumbnails(productData) {
  delete productData["Root"];

  const container = document.getElementById("productButtonsContainer");

  for (const skuId of Object.keys(productData)) {
    const product = productData[skuId];
    const defaultVariant = product.defaultVariant;
    const thumbnailPath = product.lookDetails[defaultVariant]?.thumbnails.low;

    const img = document.createElement("img");
    img.classList.add("draggable-thumbnail");
    img.id = skuId;
    img.src = coreApi.getResolvedAssetUrl(thumbnailPath);
    img.draggable = false;

    container.appendChild(img);
  }
}

Use getResolvedAssetUrl() to resolve all asset paths returned by the API to their full URLs. Thumbnail paths come in .low and .high resolutions.

Product Data Shape

PathTypeDescription
productData[skuId]objectProduct entry keyed by SKU
.defaultVariantstringName of the default look variant
.lookDetails[variant]objectAppearance details for each variant
.lookDetails[variant].thumbnails.lowstringLow-resolution thumbnail path
.lookDetails[variant].thumbnails.highstringHigh-resolution thumbnail path (if available)

Step 2: Implementing Drag

The drag interaction has three phases: start, track, and end. A floating image follows the cursor to provide visual feedback.

Floating Drag Image

Add an <img> element that will follow the cursor during a drag. It starts hidden and is shown when a drag begins.

<img id="currentlyDragging" class="currently-dragging" draggable="false" src="" alt="" />
.currently-dragging {
  position: fixed;
  top: 0;
  left: 0;
  opacity: 0;
  transition: opacity 0.5s;
  width: 100px;
  height: 100px;
  pointer-events: none;
  z-index: 3;
}

.currently-dragging.show {
  opacity: 1;
}

The pointer-events: none rule ensures the floating image doesn't intercept mouse events, which would interfere with canvas detection.

Starting a Drag

Listen for mousedown and touchstart on each thumbnail. Copy the thumbnail's src to the floating image, record which product is being dragged, and begin tracking.

let draggingImg;
let draggingImgRect;
let currentProductId;
let isDragging = false;

function setupDraggableThumbnails() {
  draggingImg = document.getElementById("currentlyDragging");
  draggingImgRect = draggingImg.getBoundingClientRect();

  const thumbnails = document.querySelectorAll(".draggable-thumbnail");

  thumbnails.forEach((thumbnail) => {
    thumbnail.addEventListener("mousedown", startDragging);
    thumbnail.addEventListener("touchstart", startDragging);
  });

  document.addEventListener("mouseup", endDragging);
  document.addEventListener("touchend", endDragging);
}

function startDragging(rawEvent) {
  rawEvent.preventDefault();
  const e = rawEvent.touches ? rawEvent.touches[0] : rawEvent;

  draggingImg.src = e.target.src;
  currentProductId = e.target.id;

  // Position the floating image before showing it
  trackDrag(rawEvent);

  document.addEventListener("mousemove", trackDrag);
  document.addEventListener("touchmove", trackDrag);

  draggingImg.classList.add("show");
}

Tracking Position and Detecting Canvas Entry

On every mousemove/touchmove, reposition the floating image and check whether the cursor has entered the configurator canvas. When it enters, call itemDragStart() to notify the configurator. When it leaves, call itemDragEnd().

function trackDrag(rawEvent) {
  rawEvent.preventDefault();
  const e = rawEvent.touches ? rawEvent.touches[0] : rawEvent;

  // Position the floating image offset by its own dimensions
  draggingImg.style.top = `${e.clientY - draggingImgRect.height}px`;
  draggingImg.style.left = `${e.clientX - draggingImgRect.width}px`;

  const overCanvas = hasEnteredCanvas(e);

  if (overCanvas && !isDragging) {
    isDragging = true;
    coreApi.itemDragStart(currentProductId);
  } else if (!overCanvas && isDragging) {
    coreApi.itemDragEnd();
    isDragging = false;
  }
}

function hasEnteredCanvas(e) {
  const target = document.elementFromPoint(e.clientX, e.clientY);
  return target && target.id === "wcEpigraphConfigurator";
}

itemDragStart(skuId) puts the configurator into placement mode, showing valid snap points. The configurator tracks the pointer internally from this point.

Ending the Drag

When the user releases the mouse or lifts their finger, clean up event listeners and reset state. If the item was over the canvas, the configurator handles placement automatically.

function endDragging() {
  if (currentProductId === undefined) return;

  coreApi.itemDragEnd();
  currentProductId = undefined;
  isDragging = false;
  draggingImg.classList.remove("show");
  document.removeEventListener("mousemove", trackDrag);
  document.removeEventListener("touchmove", trackDrag);
}

Complete Example

A full, self-contained drag-and-drop page:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Drag and Drop Configurator</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%;
    }

    #wcEpigraphConfigurator {
      width: 100%;
      height: 100%;
      position: relative;
      opacity: 0;
      z-index: 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;
      max-width: 30%;
      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;
    }

    .product-buttons-container {
      display: flex;
      flex-wrap: wrap;
      gap: 0.5rem;
      margin-top: 0.5rem;
    }

    .draggable-thumbnail {
      width: 100px;
      height: 100px;
      cursor: pointer;
      border-radius: 10px;
    }

    .currently-dragging {
      position: fixed;
      top: 0;
      left: 0;
      opacity: 0;
      transition: opacity 0.5s;
      width: 100px;
      height: 100px;
      pointer-events: none;
      z-index: 3;
    }

    .currently-dragging.show {
      opacity: 1;
    }
  </style>
</head>
<body>
  <div class="main-container">
    <div class="epg-configurator-container">
      <epigraph-configurator
        id="wcEpigraphConfigurator"
        experience-id="experience-id-provided-by-epigraph"
        disable-ui>
      </epigraph-configurator>
    </div>

    <div class="sidebar">
      <div class="category-title">Products</div>
      <p>Drag an item onto the 3D canvas to add it to the scene.</p>
      <div class="product-buttons-container" id="productButtonsContainer">
        <!-- Thumbnails populated from JS -->
      </div>
    </div>

    <img id="currentlyDragging" class="currently-dragging" draggable="false" src="" alt="" />
  </div>

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

    // --- Drag State ---
    let draggingImg;
    let draggingImgRect;
    let currentProductId;
    let isDragging = false;

    // --- Product Thumbnails ---
    function populateProductThumbnails(productData) {
      delete productData["Root"];
      const container = document.getElementById("productButtonsContainer");

      for (const skuId of Object.keys(productData)) {
        const product = productData[skuId];
        const defaultVariant = product.defaultVariant;
        const thumbnailPath = product.lookDetails[defaultVariant]?.thumbnails.low;

        const img = document.createElement("img");
        img.classList.add("draggable-thumbnail");
        img.id = skuId;
        img.src = coreApi.getResolvedAssetUrl(thumbnailPath);
        img.draggable = false;

        container.appendChild(img);
      }
    }

    // --- Drag Implementation ---
    function setupDraggableThumbnails() {
      draggingImg = document.getElementById("currentlyDragging");
      draggingImgRect = draggingImg.getBoundingClientRect();

      const thumbnails = document.querySelectorAll(".draggable-thumbnail");
      thumbnails.forEach((thumb) => {
        thumb.addEventListener("mousedown", startDragging);
        thumb.addEventListener("touchstart", startDragging);
      });

      document.addEventListener("mouseup", endDragging);
      document.addEventListener("touchend", endDragging);
    }

    function startDragging(rawEvent) {
      rawEvent.preventDefault();
      const e = rawEvent.touches ? rawEvent.touches[0] : rawEvent;

      draggingImg.src = e.target.src;
      currentProductId = e.target.id;

      trackDrag(rawEvent);

      document.addEventListener("mousemove", trackDrag);
      document.addEventListener("touchmove", trackDrag);

      draggingImg.classList.add("show");
    }

    function trackDrag(rawEvent) {
      rawEvent.preventDefault();
      const e = rawEvent.touches ? rawEvent.touches[0] : rawEvent;

      draggingImg.style.top = `${e.clientY - draggingImgRect.height}px`;
      draggingImg.style.left = `${e.clientX - draggingImgRect.width}px`;

      const overCanvas = hasEnteredCanvas(e);

      if (overCanvas && !isDragging) {
        isDragging = true;
        coreApi.itemDragStart(currentProductId);
      } else if (!overCanvas && isDragging) {
        coreApi.itemDragEnd();
        isDragging = false;
      }
    }

    function hasEnteredCanvas(e) {
      const target = document.elementFromPoint(e.clientX, e.clientY);
      return target && target.id === CONFIGURATOR_ID;
    }

    function endDragging() {
      if (currentProductId === undefined) return;

      coreApi.itemDragEnd();
      currentProductId = undefined;
      isDragging = false;
      draggingImg.classList.remove("show");
      document.removeEventListener("mousemove", trackDrag);
      document.removeEventListener("touchmove", trackDrag);
    }

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

      populateProductThumbnails(coreApi.productData);
      setupDraggableThumbnails();
    });

    configurator.addEventListener("coreApi:failed", function (event) {
      console.error("Core API failed to initialize.", event);
    });
  </script>
</body>
</html>

API Reference Summary

Methods

MethodDescription
getResolvedAssetUrl(path)Resolves a relative asset path from the API to its full URL
itemDragStart(skuId)Starts a drag operation, putting the configurator into placement mode
itemDragEnd()Ends the current drag operation

Properties

PropertyDescription
productDataObject containing all available products, keyed by SKU ID

Events

EventDescription
coreApi:readyCore API has initialized successfully
coreApi:failedCore API failed to initialize

What's Next