A strongly typed Google Model-Viewer implementation in React
Tags: react, nextjs, typescript, glb
Published: 2022-12-07 10:27:06

## 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`](https://www.npmjs.com/package/@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](https://modelviewer.dev/docs/index.html), the [examples here](https://modelviewer.dev/examples/loading/), and the [github repo here](https://github.com/google/model-viewer).

### 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. 
<br />
`src/pages/assets/glb/[glb].tsx`

```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`](https://www.npmjs.com/package/lit), [`three`](https://www.npmjs.com/package/three), and [`@types/three`](https://www.npmjs.com/package/@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:

```ts
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`

```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

![Archer-face-of-god](https://y.yarn.co/7677c468-eaf3-4d97-af7e-b2215b3f9542_text.gif)

```ts
/// <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

```ts
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

```json
{
  "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 🎉

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2c1eo9t4bd5gx5jia3dw.png)

![macbook-pro](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r2es7nm10urpl85we6gd.png)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tutect2ksco2kvoed57h.png)

Below is the final code from the same file referenced at the very start of the article (`src/pages/assets/glb/[glb].tsx`):

```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.

```css
@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