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

Posted . Visible to the public.

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:

Profile picture of Florian Leinsinger
Florian Leinsinger
Last edit
Florian Leinsinger
License
Source code in this card is licensed under the MIT License.
Posted by Florian Leinsinger to makandra dev (2026-07-02 08:59)