Overview
Over the past couple months I've been periodically working with custom hosting solutions for windows 3D vista pro tours. One of these virtual experiences happens to be a 3D tech refresh virtual marketplace where employees can trade in their existing devices for new ones. The idea is that the employees can interact with devices in this experience and project them into their space using Augmented Reality. While refactoring bits and pieces of the virtual-marketplace I once again came across the code used to render these objects -- and I was once again annoyed by the fact that I had been resorting to using dangerouslySetInnerHTML
as a workaround for rendering 3D objects in an alwaysStrict
Nextjs repo. How hard could it be after all to get the model-viewer
element from @google/model-viewer
to render as a native JSX.IntrinsicElement
?
@google/model-viewer
If you're not yet familiar with the @google/model-viewer
package you can find the docs here, the examples here, and the github repo here.
The Annoying Workaround
Below is the code used as a workaround for rendering each of the 3d objects used in the virtual marketplace experience. I've omitted the absolute URLs as I'm quite certain my employer would not be thrilled with me making those publicly accessible.
src/pages/assets/glb/[glb].tsx
import type {
GetStaticPathsResult,
GetStaticPropsContext,
GetStaticPropsResult,
InferGetStaticPropsType,
PreviewData
} from "next";
import type { ParsedUrlQuery } from "querystring";
import { LoadingDots} from "@virtual-store/ui";
import { Suspense } from "react";
import { useRouter } from "next/router";
const DeviceObj = {
UnrealMacBookPro14:
"/v1668589751/VirtualTour/UnrealMacBookPro14_ynw7dg.glb",
macbookair:
"/v1668118327/VirtualTour/macbookair_ajugky.glb",
macbook_pro_2021:
"/v1668589750/VirtualTour/macbook_pro_2021_dby3mx.glb",
delllatitude7330ruggedlaptop:
"/v1668118348/VirtualTour/delllatitude7330ruggedlaptop_ziijbe.glb",
"Lenovo M80q Tiny 2":
"/v1668118312/VirtualTour/Lenovo_M80q_Tiny_2_jwtu9h.glb",
lenovox1:
"/v1668206027/VirtualTour/lenovox1_pldnaa.glb",
lenovol13gen2:
"/v1668206076/VirtualTour/lenovol13gen2_iewiqk.glb",
genericlenovolaptop:
"/v1668118314/VirtualTour/genericlenovolaptop_rjkvc8.glb",
surfacehub:
"/v1668118332/VirtualTour/surfacehub_epkes8.glb",
surfacelaptop:
"/v1668118326/VirtualTour/surfacelaptop_yajji1.glb",
surfacelaptopstudio:
"/v1668441368/VirtualTour/surfacelaptopstudio_mopmve.glb",
SurfacePro7Plus:
"/v1668118351/VirtualTour/SurfacePro7Plus_lxn8xe.glb",
surfacestudio2:
"/v1668118329/VirtualTour/surfacestudio2_tp8dp9.glb",
thinkcentrem80qtiny:
"/v1668206530/VirtualTour/thinkcentrem80qtiny_dal7ni.glb",
thinkstationv2:
"/v1668206602/VirtualTour/thinkstationv2_o8dxo8.glb"
} as const;
const paths = [
"UnrealMacBookPro14",
"macbookair",
"macbook_pro_2021",
"delllatitude7330ruggedlaptop",
"Lenovo M80q Tiny 2",
"lenovox1",
"lenovol13gen2",
"genericlenovolaptop",
"surfacehub",
"surfacelaptop",
"surfacelaptopstudio",
"SurfacePro7Plus",
"thinkcentrem80qtiny",
"thinkstationv2"
];
const posterPath = "/v1668208003/VirtualTour/initial-load_p0yyun-Circle_yzha4d.png";
async function htmlScaffolding(glbData: keyof typeof DeviceObj) {
const glbPath = DeviceObj[glbData];
const htmlScaffold = `<!DOCTYPE html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
model-viewer {
width: calc(100vw - 20px);
height: calc(100vh - 20px);
};
</style>
</head>
<body>
<div id="card" class="bg-blend-lighten">
<model-viewer
src="${glbPath}"
id="viewer"
shadow-intensity="1"
camera-controls
auto-rotate
ar
ar-modes="webxr scene-viewer quick-look"
poster="${posterPath}"
class="modelViewer">
<button slot="ar-button" id="ar-button" class="font-normal tracking-wide text-white">View in your space</button>
</model-viewer>
</div>
<script
async
type="module"
src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</body> `;
return htmlScaffold;
}
export const getStaticPaths = async (): Promise<
GetStaticPathsResult<ParsedUrlQuery>
> => {
return {
paths: paths.map(path => `/assets/glb/${path}`),
fallback: true
};
};
const queryParamHandler = (props: string | string[] | undefined) => {
return typeof props !== "undefined"
? Array.isArray(props)
? props[0]
: props
: "";
};
interface StaticPropsResultType {
device: typeof DeviceObj[keyof typeof DeviceObj];
}
export const getStaticProps = async (
ctx: GetStaticPropsContext<ParsedUrlQuery, PreviewData>
): Promise<
GetStaticPropsResult<StaticPropsResultType>
> => {
const glb = ctx.params ? queryParamHandler(ctx.params.glb) : "lenovol13gen2";
return {
props: {
device: (await htmlScaffolding(
glb as keyof typeof DeviceObj
)) satisfies StaticPropsResultType['device']
}
};
};
export default function Glb<T extends typeof getStaticProps>({
device
}: InferGetStaticPropsType<T>) {
const router = useRouter();
return (
<>
{router.isFallback ? (
<LoadingDots />
) : (
<Suspense fallback={<LoadingDots />}>
<div className='min-h-screen min-w-[100vw]'>
<i
className='min-h-screen min-w-full'
dangerouslySetInnerHTML={{ __html: device }}
/>
</div>
</Suspense>
)}
</>
);
}
A Quick Type Reference
While we could delve into type definitions, decorators, the shadow dom, and globally accessible bits and pieces of the @google/model-viewer
package, I'll spare those details in the spirit of keeping this entry on the shorter side. I will say that the package uses lit
, three
, and @types/three
; the package, once installed and instantiated via a root script import in the src/pages/_app.tsx
file (more on that shortly), has a typedef of utility exposed for consumption via an interface in the globalThis
module. Specifically, it becomes accessible through the HTMLElementTagNameMap
interface which, assuming @google/model-viewer
and typescript
are both installed if you've made it this far, has two separate definitions in node_modules. The first primary definition for HTMLElementTagNameMap
is found in the typescript/lib/lib.dom.d.ts
file; the second definition, an augmented global declaration, is housed in the @google/model-viewer/lib/model-viewer.d.ts
file. The augmented file contains the following several-dozen lines of code:
import ModelViewerElementBase from './model-viewer-base.js';
export declare const ModelViewerElement: {
new (...args: any[]): import("./features/annotation.js").AnnotationInterface;
prototype: import("./features/annotation.js").AnnotationInterface;
} & object & {
new (...args: any[]): import("./features/scene-graph.js").SceneGraphInterface;
prototype: import("./features/scene-graph.js").SceneGraphInterface;
} & {
new (...args: any[]): import("./features/staging.js").StagingInterface;
prototype: import("./features/staging.js").StagingInterface;
} & {
new (...args: any[]): import("./features/environment.js").EnvironmentInterface;
prototype: import("./features/environment.js").EnvironmentInterface;
} & {
new (...args: any[]): import("./features/controls.js").ControlsInterface;
prototype: import("./features/controls.js").ControlsInterface;
} & {
new (...args: any[]): import("./features/ar.js").ARInterface;
prototype: import("./features/ar.js").ARInterface;
} & {
new (...args: any[]): import("./features/loading.js").LoadingInterface;
prototype: import("./features/loading.js").LoadingInterface;
} & import("./features/loading.js").LoadingStaticInterface & {
new (...args: any[]): import("./features/animation.js").AnimationInterface;
prototype: import("./features/animation.js").AnimationInterface;
} & typeof ModelViewerElementBase;
export declare type ModelViewerElement = InstanceType<typeof ModelViewerElement>;
export { RGB, RGBA } from './three-components/gltf-instance/gltf-2.0';
declare global {
interface HTMLElementTagNameMap {
'model-viewer': ModelViewerElement;
}
}
The Solution
If you haven't installed the @google/model-viewer
package yet, do so now. Then, add the following to your src/pages/_app.tsx
file. Note that while the npm package is installed in the project, attempting to reference the file where the module is located in the script components src
field (even when using an absolute path) causes Nextjs to throw an error. The quick fix for this is to use the unpkg
cdn for the @google/model-viewer
package to pull the latest version in
src/pages/_app.tsx
import "../styles/index.css";
import Script from "next/script";
import type { AppProps } from "next/app";
import Head from "next/head";
export default function NextApp({
Component,
pageProps
}: AppProps) {
// inject the next app with the latest version of `@google/model-viewer`
return (
<>
<Head>
<title>{"Virtual Marketplace"}</title>
</Head>
<Script
async
strategy='afterInteractive'
type='module'
src='https://unpkg.com/@google/model-viewer@^2.1.1/dist/model-viewer.min.js'
/>
<Component {...pageProps} />
</>
);
}
src/types/react.d.ts
In this file we'll be augmenting the JSX
namespace's IntrinsicElements
interface via a global declaration. Note that all the defs we need are accessible through the globalThis
module -- only a single triple-slash directive is required
/// <reference types="@google/model-viewer" />
export declare global {
namespace JSX {
interface IntrinsicElements {
"model-viewer": React.DetailedHTMLProps<
React.AllHTMLAttributes<
Partial<globalThis.HTMLElementTagNameMap['model-viewer']>
>,
Partial<globalThis.HTMLElementTagNameMap['model-viewer']>
>;
}
}
}
The underlying definition of the globalThis.HTMLElementTagNameMap["model-viewer"]
type is as follows
type ModelViewer = AnnotationInterface & SceneGraphInterface & StagingInterface & EnvironmentInterface & ControlsInterface & ARInterface & LoadingInterface & AnimationInterface & ModelViewerElementBase;
important -- be sure to add src/types/react.d.ts
to the include
array field in your tsconfig.json
for this JSX
augmentation to be detected and incorporated
{
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"src/types/react.d.ts"
],
}
Confirming Reacts acceptance of model-viewer
as one of its very own
If you're curious to determine whether or not this somewhat convoluted augmentation file actually did the trick, head to any .tsx
file and add a <model-viewer></model-viewer>
element in the returned tsx. You'll notice that React has indeed accepted the model-viewer element as one of its very own 🎉
Below is the final code from the same file referenced at the very start of the article (src/pages/assets/glb/[glb].tsx
):
import type {
GetStaticPathsResult,
GetStaticPropsContext,
GetStaticPropsResult,
InferGetStaticPropsType,
PreviewData
} from "next";
import type { ParsedUrlQuery } from "querystring";
import { LoadingDots} from "@virtual-store/ui";
import { Suspense } from "react";
import { useRouter } from "next/router";
const DeviceObj = {
UnrealMacBookPro14:
"/v1668589751/VirtualTour/UnrealMacBookPro14_ynw7dg.glb",
macbookair:
"/v1668118327/VirtualTour/macbookair_ajugky.glb",
macbook_pro_2021:
"/v1668589750/VirtualTour/macbook_pro_2021_dby3mx.glb",
delllatitude7330ruggedlaptop:
"/v1668118348/VirtualTour/delllatitude7330ruggedlaptop_ziijbe.glb",
"Lenovo M80q Tiny 2":
"/v1668118312/VirtualTour/Lenovo_M80q_Tiny_2_jwtu9h.glb",
lenovox1:
"/v1668206027/VirtualTour/lenovox1_pldnaa.glb",
lenovol13gen2:
"/v1668206076/VirtualTour/lenovol13gen2_iewiqk.glb",
genericlenovolaptop:
"/v1668118314/VirtualTour/genericlenovolaptop_rjkvc8.glb",
surfacehub:
"/v1668118332/VirtualTour/surfacehub_epkes8.glb",
surfacelaptop:
"/v1668118326/VirtualTour/surfacelaptop_yajji1.glb",
surfacelaptopstudio:
"/v1668441368/VirtualTour/surfacelaptopstudio_mopmve.glb",
SurfacePro7Plus:
"/v1668118351/VirtualTour/SurfacePro7Plus_lxn8xe.glb",
surfacestudio2:
"/v1668118329/VirtualTour/surfacestudio2_tp8dp9.glb",
thinkcentrem80qtiny:
"/v1668206530/VirtualTour/thinkcentrem80qtiny_dal7ni.glb",
thinkstationv2:
"/v1668206602/VirtualTour/thinkstationv2_o8dxo8.glb"
} as const;
const paths = [
"UnrealMacBookPro14",
"macbookair",
"macbook_pro_2021",
"delllatitude7330ruggedlaptop",
"Lenovo M80q Tiny 2",
"lenovox1",
"lenovol13gen2",
"genericlenovolaptop",
"surfacehub",
"surfacelaptop",
"surfacelaptopstudio",
"SurfacePro7Plus",
"thinkcentrem80qtiny",
"thinkstationv2"
];
interface StaticPropsResultType {
device: typeof DeviceObj[keyof typeof DeviceObj];
}
const posterPath =
"/v1668208003/VirtualTour/initial-load_p0yyun-Circle_yzha4d.png";
async function htmlScaffolding(
glbData: keyof typeof DeviceObj
): Promise<typeof DeviceObj[keyof typeof DeviceObj]> {
const glbPath = DeviceObj[glbData];
return glbPath;
}
export const getStaticPaths = async (): Promise<
GetStaticPathsResult<ParsedUrlQuery>
> => {
return {
paths: paths.map(path => `/assets/glb/${path}`),
fallback: true
};
};
const queryParamHandler = (props: string | string[] | undefined) => {
return typeof props !== "undefined"
? Array.isArray(props)
? props[0]
: props
: "";
};
export const getStaticProps = async (
ctx: GetStaticPropsContext<ParsedUrlQuery, PreviewData>
): Promise<
GetStaticPropsResult<StaticPropsResultType>
> => {
const glb = ctx.params ? queryParamHandler(ctx.params.glb) : "lenovol13gen2";
return {
props: {
device: (await htmlScaffolding(
glb as keyof typeof DeviceObj
)) satisfies StaticPropsResultType['device']
}
};
};
export default function Glb<T extends typeof getStaticProps>({
device
}: InferGetStaticPropsType<T>) {
const router = useRouter();
return (
<>
{router.isFallback ? (
<LoadingDots />
) : (
<Suspense fallback={<LoadingDots />}>
<div className='min-h-screen min-w-[100vw]' id='card'>
<model-viewer
src={device}
id='viewer'
shadow-intensity='1'
camera-controls
touch-action='pan-y'
auto-rotate
data-ar
ar-status='not-presenting'
ar-modes='webxr scene-viewer quick-look'
poster={posterPath}
className='modelViewer'>
<button
slot='ar-button'
id='ar-button'
className='bg-takeda-red font-normal tracking-wide text-white'>
View in your space
</button>
</model-viewer>
</div>
</Suspense>
)}
</>
);
}
For the sake of thoroughness, I've attached the contents of my global index.css
file below. Towards the very bottom of the base
layer you'll find model-viewer
targeted. I would share the github repo link itself but unfortunately that's under lock and key.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham-Book.woff2") format("woff2");
font-weight: 400;
font-display: swap;
font-style: normal;
}
/*
Chrome has a bug with transitions on load since 2012!
To prevent a "pop" of content, you have to disable all transitions until
the page is done loading.
https://lab.laukstein.com/bug/input
https://twitter.com/timer150/status/1345217126680899584
*/
body.loading * {
transition: none !important;
}
/*
Create a root stacking context
*/
#__next {
isolation: isolate;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/*
remove margin default
*/
* {
margin: 0;
}
html {
height: 100%;
box-sizing: border-box;
touch-action: manipulation;
}
body {
font-family: Gotham, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI" Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
position: relative;
line-height: calc(1em + 0.5rem);
min-height: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
}
html,
body {
background-color: var(--accents-0);
overscroll-behavior-x: none;
}
/*
sensible media defaults
*/
img,
picture,
video,
canvas,
svg,
pre {
display: block;
max-width: 100%;
}
/*
inherit fonts for form controls
*/
input,
button,
textarea,
select {
font: inherit;
}
li {
list-style: none;
}
a {
color: inherit;
text-decoration: none;
}
model-viewer {
width: calc(100% - 1.25rem);
height: calc(100vh - 1.25rem);
}
}
@layer components {
.modelViewer {
@apply h-[calc(100vh-1.25rem)] w-[100%];
}
.tooltip {
@apply invisible absolute transform-gpu transition-transform ease-in-out;
}
.has-tooltip {
@apply visible z-50 my-auto -mt-1.5 border-collapse translate-x-3
bg-opacity-75 px-1.5 py-2 text-xxs font-medium text-current;
}
}
Import a .glb file type from a remote source or as a static asset and give it a try -- it will render near instantly and should have 0 errors both locally and in prod. Feels good to get rid of that dirty dangerouslyEscapeInnerHTML
workaround used previously.
Thanks for reading along and please feel free to drop any questions, comments, or concerns below! Cheers
Top comments (4)
Your global type definition doesn't support the attributes of the model-viewer element. User @furkandindar has the same issue.
etc.
According to this post on GitHub, a better solution is the following. I'm not getting any code suggests though, but it'll have to do:
SUPER EASY SOLUTION, with that type declaration in the root you can use wherever you want and access all the properties, only thing needed is Script from 'next/script' in the same component declaration file where you use the to import the module. I was near to go with the complex three.js library, but this was very easy!
Thanks!
Hello, thanks for this awesome post! I followed this tutorial and model-viewer works with React TS on my project now. I am now able to play with src, camera-controls, shadow-softness, shadow-intensity properties but when I try to use exposure property, it's giving me an error. I checked your code you didn't use exposure property on your model-viewer. Do you have any idea about this? Interestingly, only exposure property doesn't work