klyv-ui

Panorama Viewer

Component

About

A WebGL 360° equirectangular photo viewer with drag, pinch, scroll-zoom and keyboard controls, auto-rotate, and a scrollable flat-image fallback for browsers without WebGL.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Panorama Viewer to your project and fits it to your theme.

Code

How Panorama Viewer is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import PanoramaViewer from "@/components/ui/panorama-viewer";

import { useEffect, useState } from "react";

const BLANK =
  "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";

/** An equirectangular test scene: sky, sun, hills, a grid every 15° and the compass on the horizon. */
function makePanorama(): string {
  const canvas = document.createElement("canvas");
  canvas.width = 2048;
  canvas.height = 1024;
  const context = canvas.getContext("2d");
  if (!context) return BLANK;
  const w = canvas.width;
  const h = canvas.height;
  const sky = context.createLinearGradient(0, 0, 0, h / 2);
  sky.addColorStop(0, "#1d3b8b");
  sky.addColorStop(0.7, "#5b9be6");
  sky.addColorStop(1, "#f7d9a0");
  context.fillStyle = sky;
  context.fillRect(0, 0, w, h / 2);
  const ground = context.createLinearGradient(0, h / 2, 0, h);
  ground.addColorStop(0, "#5f8f3a");
  ground.addColorStop(1, "#23391a");
  context.fillStyle = ground;
  context.fillRect(0, h / 2, w, h / 2);

  context.fillStyle = "#fff4c7";
  context.beginPath();
  context.arc(w * 0.625, h / 2 - (20 / 180) * h, 30, 0, Math.PI * 2);
  context.fill();

  context.fillStyle = "#3f6b2f";
  context.beginPath();
  context.moveTo(0, h / 2);
  for (let x = 0; x <= w; x += 8) {
    const a = (x / w) * Math.PI * 2;
    context.lineTo(
      x,
      h / 2 -
        28 -
        22 * Math.sin(a * 3) -
        14 * Math.sin(a * 7 + 1) -
        8 * Math.sin(a * 13),
    );
  }
  context.lineTo(w, h / 2);
  context.fill();

  context.strokeStyle = "rgba(255,255,255,0.28)";
  context.lineWidth = 2;
  for (let i = 0; i <= 24; i += 1) {
    context.beginPath();
    context.moveTo((i / 24) * w, 0);
    context.lineTo((i / 24) * w, h);
    context.stroke();
  }
  for (let i = 1; i < 12; i += 1) {
    context.beginPath();
    context.moveTo(0, (i / 12) * h);
    context.lineTo(w, (i / 12) * h);
    context.stroke();
  }
  context.strokeStyle = "rgba(255,255,255,0.8)";
  context.beginPath();
  context.moveTo(0, h / 2);
  context.lineTo(w, h / 2);
  context.stroke();

  context.textAlign = "center";
  context.textBaseline = "middle";
  context.lineJoin = "round";
  const label = (text: string, u: number, size: number, y = h / 2 - 70) => {
    context.font = `800 ${size}px system-ui, sans-serif`;
    context.lineWidth = size / 6;
    context.strokeStyle = "rgba(15,23,42,0.75)";
    context.fillStyle = "#ffffff";
    for (const x of [u * w, u * w - w, u * w + w]) {
      context.strokeText(text, x, y);
      context.fillText(text, x, y);
    }
  };
  const points = ["S", "SW", "W", "NW", "N", "NE", "E", "SE"];
  points.forEach((point, i) =>
    label(point, i / 8, point.length === 1 ? 72 : 44),
  );
  for (let degrees = 0; degrees < 360; degrees += 30)
    label(`${degrees}°`, (degrees / 360 + 0.5) % 1, 22, h / 2 + 40);
  label("ZENITH", 0.5, 40, 40);
  label("NADIR", 0.5, 40, h - 40);
  return canvas.toDataURL("image/jpeg", 0.9);
}

/**
 * Harvested from the Klyv docs' own PanoramaViewer example (src/site/examples/media-capture.tsx):
 * same synthetic equirectangular scene generator and the same copy. The docs' auto-rotate toggle
 * used Klyv's own `Switch` component (not part of this draft's dependency set), so it is
 * substituted here with a plain native checkbox styled the same way.
 */
export default function PanoramaViewerDemo() {
  const [src, setSrc] = useState<string | null>(null);
  const [spin, setSpin] = useState(true);
  useEffect(() => {
    setSrc(makePanorama());
  }, []);

  return (
    <div className="flex w-full flex-col gap-3">
      <label className="flex items-center gap-2 self-start text-[12px] font-semibold text-ink-soft">
        <input
          type="checkbox"
          role="switch"
          aria-checked={spin}
          checked={spin}
          onChange={(event) => setSpin(event.target.checked)}
          className="size-4 accent-accent"
        />
        Start with auto-rotate
      </label>
      {src && (
        <PanoramaViewer
          key={String(spin)}
          src={src}
          alt="Test scene: hills under an evening sky, compass points on the horizon"
          autoRotate={spin}
          height={380}
        />
      )}
    </div>
  );
}

More by klyv-ui

View all
  • Gantt Chart

    Gantt Chart

    56
  • Klyv Animated Beam

    Klyv Animated Beam

    47
  • Pareto Chart

    Pareto Chart

    40
  • Shader Canvas

    Shader Canvas

    35
  • Sunburst Chart

    Sunburst Chart

    23
  • Morphing Text

    Morphing Text

    21
  • Gooey Loader

    Gooey Loader

    20
  • Gravity Tags

    Gravity Tags

    17