Embedding Pannellum (360° panorama viewer) in an esbuild/Rails app

TL;DR: Pannellum Show archive.org snapshot is a small (~56 kB, own WebGL renderer, no three.js) equirectangular panorama viewer. The npm package has no ES module export - importing it runs an IIFE that assigns the API to window.pannellum. Bundle it statically, make sure the viewer's container gets a definite size, and allow blob: in your img-src CSP.

Considered Photo Sphere Viewer Show archive.org snapshot too - nicer default navbar, but it pulls in three.js (~600 kB lazy chunk) vs Pannellum's ~56 kB. For a simple equirectangular viewer, Pannellum wins on weight.

Install

// package.json
"dependencies": { "pannellum": "^2.5.7" }

Import

node_modules/pannellum/build/pannellum.js ends with window.pannellum = (function(...){ ... })(window, document). There is no export, so the import is a pure side effect:

import 'pannellum'                     // runs the IIFE -> sets window.pannellum
import 'pannellum/build/pannellum.css' // viewer styles
// use window.pannellum.viewer(...)

Do not rely on the import's value - under esbuild (await import('pannellum')).default is {}. Read window.pannellum.

At ~56 kB there's no need for a lazy import() chunk; a static import into the main bundle is fine.

Minimal viewer

const viewer = window.pannellum.viewer(container, {
  type: 'equirectangular',
  panorama: '/path/to/equirectangular.jpg',
  autoLoad: true,
})

Container sizing

Pannellum turns your container into .pnlm-container { width: 100%; height: 100% }. So the size must come from a parent with a definite size - a width: fit-content parent collapses to 0 and you get a blank 0×0 canvas.

.panorama            // give the parent a real size
  width: 480px
  aspect-ratio: 3 / 2
.panorama--viewer    // Pannellum fills this
  width: 100%
  height: 100%

Content Security Policy

Pannellum loads the panorama texture via URL.createObjectURL, so allow blob images:

# config/initializers/content_security_policy.rb
policy.img_src :self, :https, :data, :blob

Custom controls + fullscreen

Hide Pannellum's default UI (showControls: false) and wire your own buttons via the viewer API Show archive.org snapshot . Put the controls inside the viewer element - otherwise they disappear in fullscreen, because Pannellum makes that element the fullscreen target.

viewer.setHfov(viewer.getHfov() - 15)   // zoom in
viewer.setHfov(viewer.getHfov() + 15)   // zoom out
viewer.setPitch(viewer.getPitch() + 15) // pan up
viewer.setYaw(viewer.getYaw() + 15)     // pan right
viewer.toggleFullscreen()

preview and title

  • preview: '/poster.jpg' - a flat image shown while the panorama loads. Great for a seamless "click a poster → open the viewer" transition.
  • title: '...' - overlay text. Gotcha: only set the key when you actually have a value. title: undefined (or null) renders the literal string "undefined" in the title box. Its default position is bottom-left; move it with .pnlm-panorama-info { top: 0; bottom: auto }.
const config = { 
  type: 'equirectangular',
  panorama: url,
  preview: poster,
  autoLoad: true,
  showControls: false
}
if (title) config.title = title   // never pass an empty title

Full example (Unpoly compiler, click-to-open)

Reduced from a real project - a poster that mounts the viewer on click, with custom controls:

// app/components/panorama.js
import 'pannellum'
import 'pannellum/build/pannellum.css'

up.compiler('.panorama', (panorama, data) => {
  const openButton = panorama.querySelector('.panorama--open')
  const container = panorama.querySelector('.panorama--viewer')
  let viewer

  openButton.addEventListener('click', () => {
    if (viewer) return
    panorama.classList.add('-viewing')

    const config = {
      type: 'equirectangular',
      panorama: data.panoramaSrc,
      preview: data.panoramaPreview, // reuse the poster while loading
      autoLoad: true,
      showControls: false,
    }
    if (data.panoramaTitle) config.title = data.panoramaTitle
    viewer = window.pannellum.viewer(container, config)
  })

  up.on(panorama, 'click', '[data-panorama-action]', (event, button) => {
    if (!viewer) return
    switch (button.getAttribute('data-panorama-action')) {
      case 'pan-up':     viewer.setPitch(viewer.getPitch() + 15); break
      case 'pan-down':   viewer.setPitch(viewer.getPitch() - 15); break
      case 'pan-left':   viewer.setYaw(viewer.getYaw() - 15); break
      case 'pan-right':  viewer.setYaw(viewer.getYaw() + 15); break
      case 'zoom-in':    viewer.setHfov(viewer.getHfov() - 15); break
      case 'zoom-out':   viewer.setHfov(viewer.getHfov() + 15); break
      case 'fullscreen': viewer.toggleFullscreen(); break
    }
  })

  return () => viewer?.destroy()
})
-# poster doubles as Pannellum's `preview`; controls live INSIDE the viewer so they survive fullscreen
.panorama{ data: { panorama_src: web_url, panorama_preview: poster_url, panorama_title: title } }
  %button.panorama--open{ type: 'button' }
    = image_tag poster_url, draggable: false
  .panorama--viewer
    .panorama--controls
      %button{ data: { panorama_action: 'zoom-in' } }= icon(:zoom_in)
      %button{ data: { panorama_action: 'fullscreen' } }= icon(:fullscreen)

Links:

Florian Leinsinger