Three.js From Zero · Article s11-07
S11-07 The Shopify 3D Viewer Integration Recipe
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>.
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.
4. The merchant side — uploading a 3D model
What you'll walk a merchant through, in order, the first time:
- Source asset → glTF binary (
.glb). Use Blender's glTF 2.0 exporter; check "Apply Modifiers", "Selected Objects", and DRACO compression off (Shopify re-compresses). - Open Products → [product] → Media in Shopify admin.
- Drag the
.glbin. 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.) - Reorder media so the 3D model is first; that becomes the hero on PDP.
- Save. Visit the storefront.
<model-viewer>renders.
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.
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
| Metric | Shopify recommended | Why |
|---|---|---|
| .glb file size | < 12MB | Mobile data caps; model-viewer streaming |
| Texture max dimension | 2048 × 2048 | Mobile GPU memory; KTX2 if you must go higher |
| Triangle count | < 200k | Mid-tier Android sustains 60fps |
| Materials | < 8 unique | Material binding cost, atlas if more |
| Embedded animations | < 3 clips, < 200 KB | Memory; iOS Quick Look limits |
| USDZ size (iOS) | < 25MB | Quick 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 (prune → dedup → weld → resize → meshopt → uastc) 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 item | Junior ($) | Mid ($) | Senior ($) |
|---|---|---|---|
| Asset prep (1 SKU): glTF cleanup + texture optimize + USDZ | 200–400 | 400–800 | 800–1500 |
| Theme integration (Liquid or Hydrogen) | 300–600 | 600–1200 | 1200–2500 |
| Hotspot annotations (5 hotspots, 1 product) | 200–400 | 400–800 | 800–1500 |
| Analytics event wiring + dashboard setup | 400–800 | 800–1500 | 1500–3000 |
| Variant-color swap UI | 500–1000 | 1000–2500 | 2500–5000 |
| Custom AR CTA + brand-matched button states | 200–400 | 400–800 | 800–1500 |
| Single-SKU rollout total | 1800–3600 | 3600–7600 | 7600–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:
- 27–40% conversion lift on PDPs with 3D (Shopify case studies, average across verticals).
- 40% return-rate reduction on furniture / fashion (industry average; Article + IKEA Place data).
- $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-transformbefore every upload. - Shopify
<model-viewer>is the entry point. Custom Three.js (next article — the S11 finale) is the upsell.