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(ornull) 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:
- Docs / overview: https://pannellum.org/documentation/overview/ Show archive.org snapshot
- Config reference: https://pannellum.org/documentation/reference/ Show archive.org snapshot
- Examples: https://pannellum.org/documentation/examples/ Show archive.org snapshot - esp. preview-image Show archive.org snapshot , title-author Show archive.org snapshot , custom-controls Show archive.org snapshot
- GitHub: https://github.com/mpetroff/pannellum Show archive.org snapshot