Tutorial May 18, 2026

Add a 3D Viewer to React — Simpler Than react-three-fiber

react-three-fiber is powerful, but sometimes you just want to show a 3D model without learning an entire rendering framework. Here's the zero-dependency alternative.

The react-three-fiber Problem

react-three-fiber (R3F) is an incredible piece of engineering. It bridges React's declarative component model with Three.js's imperative 3D rendering pipeline. If you're building a 3D game, a complex data visualization, or an interactive experience where you need fine-grained control over every vertex, light, and shader, R3F is the right tool.

But if you just want to display a 3D model on a product page — let visitors rotate it, zoom in, maybe trigger an AR view on mobile — R3F is massive overkill. Installing R3F means pulling in three.js (1.2 MB minified), @react-three/fiber, @react-three/drei (for helpers like OrbitControls and loaders), and potentially @react-three/postprocessing if you want nice visuals. Your bundle grows by hundreds of kilobytes, and you're writing JSX like this:

import { Canvas } from '@react-three/fiber'
import { OrbitControls, useGLTF } from '@react-three/drei'

function Model({ url }) {
  const { scene } = useGLTF(url)
  return <primitive object={scene} />
}

function Viewer() {
  return (
    <Canvas camera={{ position: [0, 1, 3] }}>
      <ambientLight intensity={0.5} />
      <directionalLight position={[5, 5, 5]} />
      <Model url="/model.glb" />
      <OrbitControls />
    </Canvas>
  )
}

That's seven imports and a dozen lines of code to display a model with basic orbit controls. And if something breaks — a model doesn't load, the lighting looks wrong, the canvas doesn't resize — you're debugging Three.js, which has its own learning curve entirely separate from React.

The Web Component Alternative

Web components are custom HTML elements that work natively in every modern browser. They don't care what framework you're using — React, Vue, Svelte, Angular, or plain HTML. A web component like <geometry-viewer> registers itself as a browser element, and React renders it like any other DOM element.

Here's the same viewer in React using GeometryViewer's web component:

function Viewer() {
  return (
    <geometry-viewer
      src="https://geometryviewer.com/v/abc123"
      style={{ width: '100%', height: '500px' }}
      auto-rotate
      camera-controls
    />
  )
}

That's it. One element, two attributes, no imports from any 3D library. The viewer handles its own WebGL context, lighting, camera, controls, and model loading internally.

Setting It Up in a React Project

Step 1: Load the Script

You need to load the GeometryViewer embed script once in your application. The cleanest way is to add it to your index.html (or whatever HTML shell your React app uses):

<script type="module" src="https://geometryviewer.com/embed.js"></script>

If you prefer to load it programmatically (for example, only on pages that need 3D), you can use a useEffect hook:

import { useEffect } from 'react'

function useGeometryViewer() {
  useEffect(() => {
    if (customElements.get('geometry-viewer')) return
    const script = document.createElement('script')
    script.type = 'module'
    script.src = 'https://geometryviewer.com/embed.js'
    document.head.appendChild(script)
  }, [])
}

Call this hook in any component that renders a <geometry-viewer> element. The customElements.get check prevents loading the script twice if multiple components mount simultaneously.

Step 2: Use the Component

Now you can use <geometry-viewer> anywhere in your JSX:

function ProductViewer({ modelUrl }) {
  useGeometryViewer()

  return (
    <div style={{ maxWidth: 800, margin: '0 auto' }}>
      <geometry-viewer
        src={modelUrl}
        style={{ width: '100%', height: '500px', borderRadius: '12px' }}
        auto-rotate
        camera-controls
      />
    </div>
  )
}

React passes attributes to custom elements as HTML attributes, which is exactly what <geometry-viewer> expects. Boolean attributes like auto-rotate and camera-controls work as-is.

Step 3: TypeScript Support (Optional)

If you're using TypeScript, you'll get a warning that geometry-viewer is not a known JSX element. Fix this by declaring it in a type definition file:

// types/geometry-viewer.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'geometry-viewer': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        src?: string
        'auto-rotate'?: boolean
        'camera-controls'?: boolean
        ar?: boolean
      },
      HTMLElement
    >
  }
}

This gives you autocomplete and type checking for the web component's attributes.

react-three-fiber vs GeometryViewer: When to Use Which

This isn't a competition — the two tools solve different problems. Here's an honest comparison to help you choose.

Use react-three-fiber when:

Use GeometryViewer's web component when:

Bundle size comparison

This is where the difference is starkest. react-three-fiber with drei adds roughly 350-500 KB to your production bundle (after tree-shaking, gzipped). The GeometryViewer web component is loaded from a CDN and adds 0 KB to your bundle — it's an external script that loads asynchronously and doesn't touch your build process.

For a product page where 3D viewing is a feature rather than the core experience, the bundle size argument is decisive. Every kilobyte in your React bundle affects Time to Interactive. An external CDN-loaded script does not.

Handling Events

The <geometry-viewer> element dispatches standard DOM events that React can listen to. Use ref to attach event listeners:

import { useRef, useEffect } from 'react'

function Viewer({ modelUrl, onModelLoaded }) {
  const viewerRef = useRef(null)

  useEffect(() => {
    const el = viewerRef.current
    if (!el) return
    const handler = () => onModelLoaded?.()
    el.addEventListener('load', handler)
    return () => el.removeEventListener('load', handler)
  }, [onModelLoaded])

  return (
    <geometry-viewer
      ref={viewerRef}
      src={modelUrl}
      style={{ width: '100%', height: '500px' }}
      camera-controls
    />
  )
}

You can listen for load, error, and progress events. This is useful for showing loading spinners, error messages, or analytics tracking.

Server-Side Rendering (SSR)

If you're using Next.js, Remix, or another SSR framework, the web component approach works without any special configuration. The <geometry-viewer> element renders as an unknown HTML element on the server (which is valid HTML), and the browser hydrates it when the script loads on the client. No "window is not defined" errors, no dynamic imports needed.

react-three-fiber, by contrast, requires careful handling in SSR environments because Three.js depends on browser APIs. You typically need to wrap R3F components in dynamic imports with ssr: false or use Suspense boundaries.

Migration Path

If you're currently using react-three-fiber just to display models and you want to simplify, the migration is straightforward. Replace your Canvas/Scene/Model component tree with a single <geometry-viewer> element. Remove the @react-three/* dependencies. You'll likely recover 300+ KB of bundle size and eliminate a category of bugs related to Three.js context management.

If you later need more control than the web component provides, you can always add react-three-fiber back for specific components while keeping <geometry-viewer> for simpler viewing needs. The two approaches coexist without conflicts.

Two lines, zero dependencies

Load the script, use the element. No npm install, no build config, no Three.js learning curve.

View Embed Documentation