logo

Optimal Custom Integration

Build a complete ecommerce product page around one persistent OV25 iframe, including external options, product switching, synchronized loading, lifestyle photography and live angle thumbnails.

The product page below combines the hosted OV25 iframe with a fully custom storefront. OV25 handles the 3D experience; your application handles everything around it: navigation, product copy, size cards, option controls, lifestyle photography, price, basket and URL history.

A complete custom product page built around the hosted OV25 configurator

This architecture gives you a completely custom storefront without rebuilding the configurator. It works with React, Vue, another framework or plain JavaScript because the integration boundary is the browser's standard postMessage API.

The architecture

Keep one iframe mounted for the lifetime of the product-page experience. Build your interface in the parent page and treat messages from OV25 as its live source of configurator state.

Your product pageHosted OV25 iframe
Header, breadcrumbs and product copy3D scene and camera controls
Lifestyle photography and gallery layoutProduct models, materials and configuration rules
Size and product cardsCurrent option/group/selection state
Fabric, leg and other option controlsPrice and SKU calculation
Loading cover and spinnerRender-ready loading state
Basket, checkout and analyticsLive angle thumbnail rendering
Product routes and browser historyCanonical configuration query string

Do not unmount or reparent the iframe when the visitor changes product or navigates between views in a single-page application. Hide it with CSS when necessary and use SELECT_PRODUCT for product changes. That preserves the WebGL scene, avoids a fresh handshake and makes switching much faster.

Static pages and client-side navigation work together

You do not have to choose between SEO-friendly ecommerce pages and a smooth single-page experience. We recommend combining both:

  • Pre-render every product and category URL with static generation, or server-render it when the content must be request-specific.
  • Include the product title, description, canonical URL, structured data, price fallback and poster image in the initial HTML.
  • Hydrate the page in the browser, then use your framework's client-side router for subsequent navigation.
  • Place the iframe in a shared product layout so product-to-product route changes update the surrounding content without unmounting the configurator.
  • On each client-side product change, update the route and send SELECT_PRODUCT to the existing iframe.

The result is the best of both worlds: a direct visit or refresh receives a complete, cacheable page, while navigation after hydration feels instant and preserves the live 3D session.

In frameworks with persistent layouts, such as the Next.js App Router, put the configurator shell in the shared layout and statically generate each product route beneath it. In other stacks, use the same principle with a persistent application shell and the History API. The important detail is that the product content can change while the iframe DOM node stays in place.

1. Prepare the embed

Create a Product Configurator Access API key, add every production and preview hostname to your authorized domains, and collect the OV25 IDs of the products the page can switch between.

The product path can contain one ID or several IDs joined with hyphens. Loading the relevant range up front lets the parent switch products without replacing the iframe URL.

const CONFIGURATOR_ORIGIN = 'https://configurator.orbital.vision';
const apiKey = 'YOUR_PRODUCT_CONFIGURATOR_ACCESS_KEY';
const productIds = [6935, 6936, 6937, 6938, 6939, 6940];
 
const query = new URLSearchParams({
  cameraLocks: 'vertical',
  maxDistance: '6',
  minZoom: '1',
  hideGestureHint: 'true',
  cutoutAngles: '-45,0,-90,180',
  cutoutSize: '320',
});
 
const configuratorUrl =
  `${CONFIGURATOR_ORIGIN}/${apiKey}/${productIds.join('-')}?${query}`;

For a single product, use one ID. You can also use the documented /range/[RANGE_ID] URL when a range is the better catalogue boundary. Keep the product list relevant to the page: every included product contributes data to the initial payload.

Embed it with a useful title and the browser permissions needed by optional AR/VR features:

<iframe
  id="ov25-configurator"
  title="Configure your product in 3D"
  src="https://configurator.orbital.vision/YOUR_KEY/6935-6936-6937"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking; fullscreen"
  allowfullscreen
></iframe>

If the viewer is above the fold, do not lazy-load it. Give its container a stable aspect ratio so the page does not jump while the iframe starts.

.product-viewer {
  position: relative;
  aspect-ratio: 4 / 3;
  min-width: 0;
  background: #f6f6f6;
  overflow: hidden;
}
 
.product-viewer iframe {
  width: 100%;
  height: 100%;
  border: 0;
}

Use an initial image while the 3D view loads

Show a product poster immediately and load the iframe underneath it. The poster gives the page a complete first paint while the model, materials and first frame are prepared; it should not delay the iframe itself.

Use a product image from your CMS or a stable Product Cutout Image. Give it the same aspect ratio and framing as the viewer so the transition does not jump.

<div className={`product-viewer ${viewerReady ? 'is-ready' : ''}`}>
  <iframe
    ref={iframeRef}
    title="Configure your product in 3D"
    src={configuratorUrl}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking; fullscreen"
  />
 
  <img
    className="viewer-poster"
    src={product.posterUrl}
    alt={product.name}
    aria-hidden={viewerReady}
  />
 
  {!viewerReady && (
    <div className="viewer-loading" role="status" aria-live="polite">
      <span className="spinner" aria-hidden="true" />
      <span className="sr-only">Loading 3D view</span>
    </div>
  )}
</div>

Layer the poster above the iframe and remove it with a short opacity transition only after the correct first frame is ready:

.viewer-poster,
.viewer-loading {
  position: absolute;
  inset: 0;
}
 
.viewer-poster {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: #f6f6f6;
  opacity: 1;
  pointer-events: none;
  transition: opacity 160ms ease-out;
}
 
.product-viewer.is-ready .viewer-poster {
  opacity: 0;
}
 
.viewer-loading {
  display: grid;
  place-items: center;
  pointer-events: none;
}
 
@media (prefers-reduced-motion: reduce) {
  .viewer-poster { transition: none; }
}

Track both the requested product and the render state. An iframe load event only means that its document loaded; it does not mean the product is ready to reveal.

let desiredProductId = initialProduct.ov25ProductId;
let currentProductId: number | null = null;
let sceneLoading = true;
 
function updateViewerReady() {
  const ready = currentProductId === desiredProductId && !sceneLoading;
  setViewerReady(ready);
}
 
function updateStore(type: string, data: any) {
  switch (type) {
    case 'CURRENT_PRODUCT_ID':
      currentProductId = Number(data);
      updateViewerReady();
      break;
    case 'IS_LOADING':
      sceneLoading = Boolean(data);
      updateViewerReady();
      break;
  }
}

If the first gallery must appear as one complete unit, add cutoutsReady to the reveal condition. Use a short fallback so a failed thumbnail capture never leaves the visitor permanently behind the poster. On later product switches, reuse the same pattern with the incoming product's poster; do not unmount the iframe.

2. Create a safe message bridge

Standard OV25 messages use { type, payload }; payload is normally JSON serialized. Live cutouts are the exception: their transferable ImageBitmap objects are top-level fields so the browser can move them without copying.

Always validate both the sender origin and the sender window. Send commands to the exact configurator origin rather than "*" in production.

const iframe = document.querySelector<HTMLIFrameElement>('#ov25-configurator')!;
 
function parsePayload(payload: unknown) {
  if (typeof payload !== 'string') return payload;
  return JSON.parse(payload);
}
 
function sendToConfigurator(type: string, payload: unknown) {
  iframe.contentWindow?.postMessage(
    { type, payload: JSON.stringify(payload) },
    CONFIGURATOR_ORIGIN,
  );
}
 
function onConfiguratorMessage(event: MessageEvent) {
  if (event.origin !== CONFIGURATOR_ORIGIN) return;
  if (event.source !== iframe.contentWindow) return;
 
  const message = event.data;
  if (!message?.type) return;
 
  if (message.type === 'CUTOUT_THUMBNAILS') {
    receiveCutouts(message.bitmaps, message.yaws);
    return;
  }
 
  let data: unknown;
  try {
    data = parsePayload(message.payload);
  } catch (error) {
    console.error('Invalid OV25 message payload', message, error);
    return;
  }
 
  updateStore(message.type, data);
}
 
window.addEventListener('message', onConfiguratorMessage);
 
// On teardown:
// window.removeEventListener('message', onConfiguratorMessage);

Use the matching localhost origin while developing locally. Do not weaken the production origin check to make local testing convenient.

3. Mirror the configurator state

These are the core messages for a custom product page:

MessageUse it for
ALL_PRODUCTSBuild product/size cards and map OV25 IDs to your routes.
CURRENT_PRODUCT_IDConfirm which product the iframe is currently displaying.
CONFIGURATOR_STATERender external option controls and read the selected configuration.
SELECTED_SELECTIONSObserve a lightweight selection-only update when you do not need the full option tree.
CURRENT_PRICEUpdate the displayed configured price. Prefer formattedPrice when available.
CURRENT_SKUBuild the configured basket line in your commerce system.
CURRENT_QUERY_STRINGKeep the page URL shareable and restore the exact configuration on refresh.
IS_LOADINGKnow when the correct newly configured frame has actually been presented.
CUTOUT_THUMBNAILSPopulate gallery tiles with renders of the current live configuration.
ERRORShow a recoverable error state and log integration failures.

A small parent-side store is enough:

type ConfiguratorStore = {
  products: Array<{ id: number; name: string; thumbnailUrl?: string }>;
  currentProductId: number | null;
  configuration: ConfiguratorState | null;
  price: PricePayload | null;
  sku: SkuPayload | null;
  loading: boolean;
  error: string | null;
};
 
function updateStore(type: string, data: any) {
  switch (type) {
    case 'ALL_PRODUCTS':
      setStore((state) => ({ ...state, products: data }));
      break;
    case 'CURRENT_PRODUCT_ID':
      setStore((state) => ({ ...state, currentProductId: Number(data) }));
      break;
    case 'CONFIGURATOR_STATE':
      latestConfiguration.current = data;
      setStore((state) => ({ ...state, configuration: data }));
      break;
    case 'CURRENT_PRICE':
      setStore((state) => ({ ...state, price: data }));
      break;
    case 'CURRENT_SKU':
      setStore((state) => ({ ...state, sku: data }));
      break;
    case 'CURRENT_QUERY_STRING': {
      const url = new URL(window.location.href);
      url.search = String(data);
      window.history.replaceState(window.history.state, '', url);
      break;
    }
    case 'IS_LOADING':
      handleLoadingChange(Boolean(data));
      break;
    case 'ERROR':
      setStore((state) => ({
        ...state,
        error: typeof data === 'string' ? data : data?.message ?? 'Configurator error',
      }));
      break;
  }
}

The interfaces above are intentionally abbreviated. Generate your exact app types from the payloads you consume, and see the API / Custom Integration reference for every field and message.

4. Render the options outside the iframe

CONFIGURATOR_STATE contains the current option tree plus selectedSelections. Render those rows using your storefront design system and send the selected IDs back to OV25.

function ProductOptions({ state }: { state: ConfiguratorState }) {
  return state.options.map((option) => (
    <fieldset key={option.id}>
      <legend>{option.name}</legend>
 
      {option.groups.map((group) =>
        group.selections.map((selection) => {
          const selected = state.selectedSelections.some(
            (item) => item.optionId === option.id &&
              item.groupId === group.id &&
              item.selectionId === selection.id,
          );
 
          return (
            <button
              key={selection.id}
              type="button"
              aria-pressed={selected}
              onClick={() => sendToConfigurator('SELECT_SELECTION', {
                optionId: option.id,
                groupId: group.id,
                selectionId: selection.id,
              })}
            >
              {selection.name}
            </button>
          );
        }),
      )}
    </fieldset>
  ));
}

The ID payload is deterministic and recommended. A single display-name pair such as { Legs: "Medium Oak" } is also supported when your product catalogue cannot store OV25 IDs; see SELECT_SELECTION in the API reference for its fuzzy-matching rules.

The persistent 3D scene, gallery, product cards and external fabric controls updating together

5. Switch products without reloading the iframe

Map each product or size to its OV25 product ID. Update the parent route immediately, keep the iframe mounted, then send:

function selectProduct(product: RetailerProduct) {
  desiredProductId.current = product.ov25ProductId;
  showProductPoster(product.posterUrl);
  setParentLoading(true);
 
  window.history.pushState({}, '', product.path);
  sendToConfigurator('SELECT_PRODUCT', product.ov25ProductId);
}

SELECT_PRODUCT_RECEIVED acknowledges the command, but it is not the reveal signal. Keep the poster or loading cover in place until:

  1. CURRENT_PRODUCT_ID equals the requested ID.
  2. IS_LOADING is false.
  3. Any gallery assets that you require for an atomic reveal are ready.

This distinction prevents a stale frame from the previous product flashing between loading phases.

The example page uses this order:

  1. Interactive 360° view.
  2. Lifestyle image from your CMS.
  3. Four live cutouts at -45, 0, -90 and 180 degrees.

The 360 view, lifestyle image and four configuration-matched cutout angles in one gallery

The lifestyle image comes from your CMS. The remaining angle images arrive from OV25 as one complete CUTOUT_THUMBNAILS set. Convert each bitmap to an object URL, close the bitmap, then replace the previous set together:

const CUTOUT_ORDER = [-45, 0, -90, 180];
let currentCutoutUrls: string[] = [];
 
async function bitmapToObjectUrl(bitmap: ImageBitmap) {
  try {
    const canvas = document.createElement('canvas');
    canvas.width = bitmap.width;
    canvas.height = bitmap.height;
    canvas.getContext('2d')!.drawImage(bitmap, 0, 0);
 
    const blob = await new Promise<Blob>((resolve, reject) => {
      canvas.toBlob(
        (value) => value ? resolve(value) : reject(new Error('PNG conversion failed')),
        'image/png',
      );
    });
    return URL.createObjectURL(blob);
  } finally {
    bitmap.close();
  }
}
 
async function receiveCutouts(bitmaps: ImageBitmap[], yaws: number[]) {
  if (!Array.isArray(bitmaps) || !Array.isArray(yaws)) return;
 
  const next = await Promise.all(bitmaps.map(async (bitmap, index) => ({
    yaw: yaws[index],
    url: await bitmapToObjectUrl(bitmap),
  })));
 
  next.sort((a, b) => CUTOUT_ORDER.indexOf(a.yaw) - CUTOUT_ORDER.indexOf(b.yaw));
  if (next.length !== CUTOUT_ORDER.length) {
    next.forEach(({ url }) => URL.revokeObjectURL(url));
    return;
  }
 
  const previous = currentCutoutUrls;
  currentCutoutUrls = next.map(({ url }) => url);
  renderCutoutTiles(next);
  previous.forEach((url) => URL.revokeObjectURL(url));
}

When a cutout tile is clicked, rotate and lock the live viewer to the same angle. The 360° tile releases the camera again:

function selectCutout(yawDeg: number) {
  sendToConfigurator('SELECT_CUTOUT_ANGLE', { yawDeg });
}
 
function select360() {
  sendToConfigurator('SELECT_CUTOUT_ANGLE', { yawDeg: null });
}

Keep the iframe mounted underneath any lifestyle image or product poster shown in the main media area. Switching gallery items should change visibility, not reconstruct the WebGL application.

See Live Angle Thumbnails for performance guidance, lifecycle details and more angle recipes.

7. Synchronize every dependent image with the scene

The key loading rule is: receive early, reveal late.

CONFIGURATOR_STATE can announce the new fabric before the correct pixels are on screen. Use it to begin preloading your product imagery, but keep the last settled images visible. Commit the new set only when OV25 sends IS_LOADING: false.

const latestFabric = { current: '' };
const settledFabric = { current: '' };
 
function onConfigurationState(state: ConfiguratorState) {
  latestFabric.current = selectedFabricName(state);
 
  // Warm the next product-card images while OV25 renders.
  preloadRangeImages(latestFabric.current);
  renderExternalControls(state); // Controls may reflect the click immediately.
}
 
async function handleLoadingChange(isLoading: boolean) {
  setParentLoading(isLoading);
  if (isLoading || !latestFabric.current) return;
 
  await preloadRangeImages(latestFabric.current);
  settledFabric.current = latestFabric.current;
  renderAllProductCards(settledFabric.current); // One committed swap.
}

Use a visual spinner over the viewer while it is busy, with accessible text available to assistive technology rather than a visible technical status such as “Rendering”.

{loading && (
  <div className="viewer-loading" role="status" aria-live="polite">
    <span className="spinner" aria-hidden="true" />
    <span className="sr-only">Updating product</span>
  </div>
)}

For product switches, wait for the matching product ID and the idle signal. For selection changes on the same product, IS_LOADING: false is the commit point for related product-card images. Replace live cutouts only as a complete set, never tile by tile.

8. Restore URLs and complete the basket flow

When CURRENT_QUERY_STRING arrives, put it into your product-page URL with history.replaceState. A copied link will then restore the selected product configuration when it is loaded back into the iframe.

Your application owns the basket flow. At “Add to basket”, combine:

  • your product identifier and quantity;
  • CURRENT_SKU;
  • CURRENT_PRICE;
  • SELECTED_SELECTIONS or the selected values from CONFIGURATOR_STATE;
  • the current shareable product URL, if useful for customer service or saved baskets.

Validate the current product ID and ensure IS_LOADING is false before accepting the basket action. The iframe supplies the configured commerce data; your commerce backend remains responsible for inventory, tax, discounts, persistence and checkout.

9. Handle failure without trapping the visitor

  • Keep a product poster available for the iframe's first load and product transitions.
  • Log ERROR messages with their message type and product ID, then show a concise retry action.
  • If live cutouts fail, reveal the ready 3D scene after a short fallback instead of leaving a permanent loading cover.
  • Revoke old object URLs and call ImageBitmap.close() on every received bitmap, including discarded or stale sets.
  • Ignore cutout sets whose product ID no longer matches the requested product.
  • Remove the message listener and revoke all remaining object URLs when the integration is destroyed.

Production checklist

  • Product Configurator Access key created.
  • Production, preview and local hostnames authorized.
  • One stable iframe instance with a fixed-aspect-ratio container.
  • event.origin and event.source checked on every incoming message.
  • Exact production origin used as the postMessage target.
  • External controls rendered from CONFIGURATOR_STATE rather than a duplicated rules model.
  • Product cards mapped to OV25 IDs and switched with SELECT_PRODUCT.
  • Spinner driven by IS_LOADING, with no visible technical loading copy.
  • Dependent imagery staged until IS_LOADING: false.
  • Carousel ordered as 360°, lifestyle, then live cutouts.
  • Bitmaps closed and object URLs revoked.
  • Current price, SKU and selection state captured for the basket.
  • Keyboard focus, pressed states and useful alternative text provided for all controls.
  • Mobile layout tested without reloading or moving the iframe.

Once these pieces are in place, the iframe is just the rendering and configuration engine inside a page that remains completely yours. Use the full API reference when you need AR, dimensions, screenshots, analytics or the less common message types.