Three.js From Zero · Article s11-07

S11-07 The Shopify 3D Viewer Integration Recipe

Season 11 · Article 07 · Configurator

The Shopify 3D Viewer Integration Recipe

Shopify ships <model-viewer> in the Dawn theme out of the box. Most merchants don't know it. This article is the indie freelancer's recipe: upload, theme block, AR routing, hotspot annotations, analytics. Ship a 3D-enabled product page in an afternoon.

1. Why Shopify is the long-tail bet

Three flagship configurators (Polestar, Cartier, Nike By You) define the luxury end of the market. Their budgets are $200k+. Their stack is custom Three.js.

But there are 4.8 million Shopify stores, and the long-tail merchant doing $50k–$5M / year doesn't pay $200k. They pay $2k–$15k for a 3D-enabled product page that converts 30% better. That's the indie freelance market — and the entry point is <model-viewer>.

The data merchants will quote at you. Shopify's own published case studies and third-party benchmarks (Threekit, Cylindo, Hapticmedia) report 27–40% conversion lift, 70%+ time-on-page lift, and 40% return-rate reduction when 3D is well-implemented on a high-consideration product page. Even at the conservative end, a $1M / year SKU yields $270k incremental revenue from a $5k integration. The ROI math is why this is the easiest configurator job to sell.

2. The dossier in one paragraph

Shopify's "3D Models" media type was added to the admin in 2018. Dawn (and every modern Shopify theme since 2021) renders 3D media via Google's <model-viewer> web component. AR routing is automatic: iOS users get Quick Look (USDZ), Android gets Scene Viewer (glTF), and desktop falls back to the orbit-controllable canvas. Merchants upload a .glb in the admin; the storefront just works.

The opportunity is everything <model-viewer> doesn't ship by default: hotspot annotations, branded UI, analytics events, custom CTAs ("View in Your Room"), and the dual-asset USDZ pipeline. That's where the freelance hours live.

3. Live demo — a Shopify-shaped product page

Below is a fully working <model-viewer> embed loading a stock glTF (DamagedHelmet from KhronosGroup samples). It mirrors what Shopify's Dawn theme renders, with the extras a freelancer adds on top: hotspot annotations, an AR CTA, a custom analytics layer, and keyboard navigation hooks. Open the events panel to see the data your merchant gets.

loading…
Loading 3D model…
checking AR…

4. The merchant side — uploading a 3D model

What you'll walk a merchant through, in order, the first time:

  1. Source asset → glTF binary (.glb). Use Blender's glTF 2.0 exporter; check "Apply Modifiers", "Selected Objects", and DRACO compression off (Shopify re-compresses).
  2. Open Products → [product] → Media in Shopify admin.
  3. Drag the .glb in. Shopify uploads, validates, generates a poster, and — if the file is over 15MB — yells at you. (Hard limit: 500MB per file, but practical limit for mobile users is ~12MB.)
  4. Reorder media so the 3D model is first; that becomes the hero on PDP.
  5. Save. Visit the storefront. <model-viewer> renders.
Shopify auto-generates a USDZ companion server-side via their cloud pipeline. iOS users tapping the AR button get USDZ Quick Look; Android users get Scene Viewer. You don't have to ship two assets — but if you want a higher-fidelity USDZ (custom anchor, audio, Reality Composer behaviors), upload it manually as a paired media item.

5. The Liquid theme block

Dawn's product-media template already includes 3D rendering. If you're on a custom theme, here's the snippet you drop in snippets/product-media.liquid:

{% comment %} sections/product-media.liquid {% endcomment %}
{% for media in product.media %}
  {% case media.media_type %}
    {% when 'model' %}
      <product-model class="product__media-item" data-media-id="{{ media.id }}">
        {{ media | model_viewer_tag:
            image_size: '1024x',
            reveal: 'interaction',
            toggleable: true,
            data-shopify-feature: 'model-viewer'
        }}
      </product-model>
    {% when 'image' %}
      {{ media | image_url: width: 1500 | image_tag: loading: 'lazy', alt: media.alt }}
  {% endcase %}
{% endfor %}

The model_viewer_tag Liquid filter is Shopify's wrapper. It emits <model-viewer> with sensible defaults (camera-controls, ar, auto-rotate, the right poster). The data-shopify-feature attribute opts the merchant into Shopify's own analytics ping — first-party events that show up in the Shopify Analytics dashboard as "3D model interaction" funnel steps.

6. The Hydrogen / React side

If your client is on Hydrogen (Shopify's headless React framework), model-viewer still works — it's a web component, framework-agnostic. Just register it in root.tsx:

// app/root.tsx
import { Links, Meta, Outlet, Scripts } from '@remix-run/react';

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
        <script
          type="module"
          src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"
        />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

// app/components/ProductModel.tsx
export function ProductModel({ src, alt, usdz }: { src: string; alt: string; usdz?: string }) {
  return (
    <model-viewer
      src={src}
      ios-src={usdz}
      alt={alt}
      camera-controls
      auto-rotate
      ar
      ar-modes="webxr scene-viewer quick-look"
      shadow-intensity="1"
      style={{ width: '100%', height: '500px' }}
    />
  );
}

Hydrogen 2 supports custom elements in JSX with no fuss — you'll just need a TypeScript declaration if your linter complains:

// app/types/model-viewer.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'model-viewer': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        src: string; 'ios-src'?: string; alt: string;
        ar?: boolean; 'ar-modes'?: string;
        'camera-controls'?: boolean; 'auto-rotate'?: boolean;
        'shadow-intensity'?: string;
      },
      HTMLElement
    >;
  }
}

7. AR Quick Look + Scene Viewer activation

The single most-requested feature is "View in Your Room." <model-viewer> handles all three platforms with one attribute:

<model-viewer
  src="helmet.glb"
  ios-src="helmet.usdz"
  ar
  ar-modes="webxr scene-viewer quick-look"
  ar-scale="auto"
>
  <button slot="ar-button">View in your space</button>
</model-viewer>

The ar-modes attribute is a priority-ordered list. <model-viewer> probes the browser at runtime — WebXR if the headset is present (Vision Pro, Quest browser), Scene Viewer on Android Chrome, Quick Look on iOS Safari. Falls back to the orbit-only canvas everywhere else.

The USDZ catch

Shopify auto-generates USDZ server-side, but the conversion is conservative — no animations, no Reality Composer behaviors, no spatial audio. For luxury / hero product pages, generate USDZ yourself with gltf-transform usdz or Apple's Reality Composer Pro and upload it as the iOS-specific media item.

# gltf-transform CLI — open-source path
npm i -g @gltf-transform/cli
gltf-transform usdz helmet.glb helmet.usdz

8. Hotspot annotations — the production-tier upsell

This is the feature that justifies the freelance invoice over "merchant uploads a glTF." <model-viewer> ships hotspots natively. They're DOM elements with a slot="hotspot-N" attribute and a data-position:

<model-viewer src="...">
  <button slot="hotspot-1"
          data-position="0.4 0.6 0.5"
          data-normal="0.7 0.4 0.6"
          data-visibility-attribute="visible">
    Visor: polycarbonate
  </button>
</model-viewer>

The data-position is a model-space point. The data-normal is the surface normal at that point — used by <model-viewer> to hide the hotspot when it's facing away from the camera (occlusion-aware). Listen for 'hotspot-visibility' to fade them in/out.

Picking hotspot positions by hand is tedious. Add this dev-time helper that prints the position when you click the model:

// dev-only — paste in console while looking at the model
const mv = document.querySelector('model-viewer');
mv.addEventListener('click', (event) => {
  const hit = mv.positionAndNormalFromPoint(event.clientX, event.clientY);
  if (hit) {
    console.log('data-position="' + hit.position.toString() + '"');
    console.log('data-normal="'   + hit.normal.toString()   + '"');
  }
});

Click the model where you want a hotspot. Copy the values. Done. Use this one trick to bill an extra $500 per product page.

9. Subclassing model-viewer for analytics

The merchant always asks: "How many people are actually rotating the 3D model?" Shopify's first-party tracking covers load and AR launch but not deep interaction. Add it yourself by subclassing the web component:

import { ModelViewerElement } from '@google/model-viewer';

class ShopifyTrackedModelViewer extends ModelViewerElement {
  connectedCallback() {
    super.connectedCallback();
    let rotated = false, zoomed = false, hotspotClicks = 0;

    this.addEventListener('camera-change', (e) => {
      // 'camera-change' fires on user input; not on auto-rotate
      if (e.detail.source === 'user-interaction' && !rotated) {
        rotated = true;
        window.gtag?.('event', '3d_rotate', { product_id: this.dataset.productId });
      }
    });

    this.addEventListener('ar-status', (e) => {
      if (e.detail.status === 'session-started') {
        window.gtag?.('event', '3d_ar_open', { product_id: this.dataset.productId });
      }
    });

    this.querySelectorAll('[slot^="hotspot-"]').forEach((hs) => {
      hs.addEventListener('click', () => {
        hotspotClicks++;
        window.gtag?.('event', '3d_hotspot_click', {
          product_id: this.dataset.productId,
          slot: hs.slot,
        });
      });
    });

    this.addEventListener('load', () => {
      window.gtag?.('event', '3d_model_load', {
        product_id: this.dataset.productId,
        load_time_ms: performance.now(),
      });
    });
  }
}

customElements.define('shopify-tracked-mv', ShopifyTrackedModelViewer, { extends: 'model-viewer' });

Now your merchant gets 3d_rotate, 3d_ar_open, 3d_hotspot_click, and 3d_model_load events flowing into GA4 / Meta CAPI / Klaviyo / whatever stack they're on. That data is the ammo for the next invoice — you tune the page off real numbers, not vibes.

Shopify Plus stores can wire these into Shopify's own pixel via webPixel.publish('custom_event', ...) instead of GA4 directly. Cleaner attribution, single source of truth.

10. The events the demo above is firing

Look at the live event log under the demo. Every interaction — load, rotate, hotspot click, AR launch attempt, share — is logged with a timestamp. Treat that log as a preview of what your merchant will see in their analytics dashboard once you wire it up properly.

11. The sub-classed CTAs that convert

Beyond the standard "View in AR" button, three CTAs reliably move the conversion needle on Shopify product pages. Implement them as slotted children:

  • "Show me a [color] one" — variant-aware swap. Listens to Shopify's variant-change event and rebinds material colors. The picker UI lives outside the viewer; the model state syncs.
  • "Spin to compare" — a small modal with two viewers side-by-side. Pre-load both, sync camera-orbit between them. Useful for sneaker / watch comparison shopping.
  • "Try it on" / "View in room" — the AR button, but rebranded. <button slot="ar-button">Try in your living room</button>. Conversion lift from rebranding alone: ~10–15% in Shopify's published case studies.

12. The performance budget Shopify will quietly enforce

MetricShopify recommendedWhy
.glb file size< 12MBMobile data caps; model-viewer streaming
Texture max dimension2048 × 2048Mobile GPU memory; KTX2 if you must go higher
Triangle count< 200kMid-tier Android sustains 60fps
Materials< 8 uniqueMaterial binding cost, atlas if more
Embedded animations< 3 clips, < 200 KBMemory; iOS Quick Look limits
USDZ size (iOS)< 25MBQuick Look hard refuses larger

Shopify will let you upload bigger but the merchant's mobile bounce rate will punish them within a week. Run the asset through gltf-transform with the standard pipeline (prunededupweldresizemeshoptuastc) before upload. See S6-05 for the full pipeline.

13. The freelance invoice template

What you actually charge for a Shopify 3D integration, line-item by line-item:

Line itemJunior ($)Mid ($)Senior ($)
Asset prep (1 SKU): glTF cleanup + texture optimize + USDZ200–400400–800800–1500
Theme integration (Liquid or Hydrogen)300–600600–12001200–2500
Hotspot annotations (5 hotspots, 1 product)200–400400–800800–1500
Analytics event wiring + dashboard setup400–800800–15001500–3000
Variant-color swap UI500–10001000–25002500–5000
Custom AR CTA + brand-matched button states200–400400–800800–1500
Single-SKU rollout total1800–36003600–76007600–15000

Per additional SKU after the first: 30–50% of the unit cost (asset prep stays linear, integration is fixed). A 20-SKU rollout sells for $15k–$60k depending on tier.

14. The ROI pitch deck (one page, three numbers)

When the merchant asks "is this worth it?" — three numbers:

  1. 27–40% conversion lift on PDPs with 3D (Shopify case studies, average across verticals).
  2. 40% return-rate reduction on furniture / fashion (industry average; Article + IKEA Place data).
  3. $5/SKU/month hosting cost. Negligible vs the lift.

Walk through their actual SKU revenue. Multiply. Show the breakeven at week 2 of a 12-week ROI. Sign the contract.

15. What model-viewer can't do (the upsell to custom Three.js)

  • Variant-aware multi-model swap. <model-viewer> renders one model. Sneaker configurators with 25 part-slots want a single mesh and 25 material binds — that's S11-05's territory. Quote a custom Three.js viewer.
  • Snap-points / parametric assembly. Modular furniture, S11-06. Roomle-style snapping needs custom logic <model-viewer> doesn't expose.
  • Photoreal hero stage. Lusion's Polestar 4 hero scene. <model-viewer>'s default IBL isn't art-directed. For luxury / auto, drop down to Three.js + custom HDRI.
  • Server-side render-to-image. Threekit's "virtual photographer" pipeline. <model-viewer> is a viewer, not a renderer-as-a-service.
  • Splat / Gaussian rendering. Spark.js / luma.gl territory (S11-10, S11-11). <model-viewer> is mesh-only.

Each of these is a "yes, I can do that — let me quote a Three.js custom build" moment. Use the Shopify <model-viewer> integration as the foot-in-the-door, then graduate the merchant to a flagship as their catalog grows.

16. Takeaways

  • <model-viewer> is the long-tail viewer. Ships in Dawn. Free conversion lift if you bother to use it.
  • Upload a clean .glb → AR routing is automatic. Quick Look on iOS, Scene Viewer on Android, WebXR on Vision Pro / Quest.
  • Hotspots, analytics, custom AR CTA — that's the freelance work. Sub-class the web component, wire to GA4, bill accordingly.
  • Performance budget matters: <12MB glb, <200k triangles, <25MB USDZ. Run gltf-transform before every upload.
  • Shopify <model-viewer> is the entry point. Custom Three.js (next article — the S11 finale) is the upsell.