3D Interactive Sphere Photo Gallery with JavaScript and CSS

Category: Gallery , Javascript | September 23, 2025
Authorpaulnoble
Last UpdateSeptember 23, 2025
LicenseMIT
Views163 views
3D Interactive Sphere Photo Gallery with JavaScript and CSS

This is a JS/CSS Orbital Photo Gallery that creates animated, 3D photo galleries by displaying images as if they were tiled on the surface of a sphere.

This unique approach transforms traditional flat image layouts into interactive 3D experiences that users can explore through mouse drag and touch gestures to reveal images positioned around the virtual sphere.

Features:

  • 3D Sphere Mapping: Maps images onto a virtual sphere surface using CSS 3D transforms and mathematical positioning.
  • Touch and Mouse Controls: Supports both mouse drag and touch gestures for navigation across all devices.
  • Momentum Scrolling: Includes physics-based inertia effects that continue rotation after user input ends.
  • Image Enlargement: Click any image to view a full-size version with smooth zoom transitions.
  • Configurable Grid: Adjustable sphere segments and image positioning through CSS custom properties.
  • Responsive Design: Adapts to different screen sizes while maintaining the 3D sphere effect.
  • Performance Optimized: Uses hardware acceleration and transform calculations for smooth animations.

See it in action:

How to use it:

1. Add your images to the gallery. You need a main container to act as the 3D stage. Inside, a .sphere element will hold all your image items. Each image is a div with the class .item. The positioning of each image on the sphere is controlled by two data- attributes:

  • data-item="x,y": Defines the longitude (x) and latitude (y) coordinates for the item on the sphere.
  • data-item-size="w,h": Sets the width (w) and height (h) of the item in grid units.
<main>
  <div class="stage">
    <div class="sphere">
      <!-- A single image item -->
      <div class="item" data-src="path/to/your/image.jpg" data-item="-37,-4"  data-item-size="2,2">
        <div class="item__image">
          <img src="path/to/your/image.jpg" draggable="false"/>
        </div>
      </div>
      <!-- Add more items here -->
    </div>
  </div>
  <!-- Viewer elements for the lightbox effect -->
  <div class="viewer">
    <div class="scrim"></div>
    <div class="frame">
      <div class="enlarged"></div>
    </div>
  </div>
</main>

2. The CSS is the core of the 3D effect. It uses CSS variables to define the sphere’s properties, which you can easily customize.

  • --radius: The radius of the sphere. This determines its size.
  • --segments-x: The number of vertical segments, affecting horizontal distribution.
  • --segments-y: The number of horizontal segments, affecting vertical distribution.
:root {
  --radius: max(1300px, 100vw);
  --circ: calc(var(--radius) * 3.14);
  --segments-x: 37;
  --segments-y: 37;
  --sphere-rotation-y: 0;
  --sphere-rotation-x: 0;
  --offset-x: 0;
  --offset-y: 0;
  --rot-y: calc((360deg / var(--segments-x)) / 2);
  --rot-x: calc((360deg / var(--segments-y)) / 2);
  --rot-y-delta: 0deg;
  --item-width: calc((var(--circ) / var(--segments-x)));
  --item-height: calc((var(--circ) / var(--segments-y)));
  --item-size-x: 1;
  --item-size-y: 1;
  --gradient: radial-gradient(
    var(--gradient-center) 50%,
    var(--gradient-edge) 90%
  );
  --gradient-blur: radial-gradient(
    var(--gradient-center) 70%,
    var(--gradient-edge) 90%
  );
  --bg-scrim: rgba(0, 0, 0, 0.6);
  --bg: rgb(235, 235, 235);
  --item-bg: rgb(225, 225, 225);
  --gradient-center: rgba(235, 235, 235, 0);
  --gradient-edge: rgba(235, 235, 235, 0.5);
  --bg-scrim: rgba(0, 0, 0, 0.4);
  --gradient: radial-gradient(
    var(--gradient-center) 65%,
    var(--gradient-edge) 100%
  );
}
* {
  box-sizing: border-box;
}
body,
html {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  background-color: var(--bg);
}
main {
  display: flex;
  width: 100%;
  height: 100%;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  touch-action: none;
}
main * {
  touch-action: none;
}
.stage {
  perspective: calc(var(--radius) * 2);
}
.sphere {
  transform: translateZ(calc(var(--radius) * -1)) rotateY(var(--sphere-rotation-y)) rotateX(var(--sphere-rotation-x));
  transform-style: preserve-3d;
}
.overlay {
  background-image: var(--gradient);
  position: fixed;
  inset: 0;
  margin: auto;
  z-index: 3;
  content: "";
  pointer-events: none;
  opacity: 1;
}
.overlay--blur {
  mask-image: var(--gradient-blur);
  backdrop-filter: blur(3px);
  position: fixed;
  inset: 0;
  margin: auto;
  z-index: 3;
  opacity: 1;
  content: "";
  pointer-events: none;
}
.item {
  width: calc(var(--item-width) * var(--item-size-x));
  height: calc(var(--item-height) * var(--item-size-y));
  position: absolute;
  transform-origin: 50% 50%;
  top: -999px;
  bottom: -999px;
  left: -999px;
  right: -999px;
  margin: auto;
  backface-visibility: hidden;
  color: transparent;
  transform-style: preserve-3d;
  transition: transform 300ms;
  transform: rotateY(calc( var(--rot-y) * (var(--offset-x) + ((var(--item-size-x) - 1) / 2)) + var(--rot-y-delta, 0deg) )) rotateX(calc( calc(var(--rot-x) * (var(--offset-y) - ((var(--item-size-y) - 1) / 2))) + var(--rot-x-delta, 0deg) )) translateZ(var(--radius));
}
.item__image {
  transition: transform 300ms;
}
* {
  transform-style: preserve-3d;
}
iframe {
  border: 0;
}
.iframe-wrap {
  height: 52px;
  overflow: hidden;
  width: 100%;
}
.item__image img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  pointer-events: none;
  backface-visibility: hidden;
}
.item__image {
  position: absolute;
  display: block;
  inset: 10px;
  border-radius: 12px;
  background-color: var(--item-bg);
  overflow: hidden;
  backface-visibility: hidden;
}
input {
  position: absolute;
  top: 20px;
  left: 20px;
  width: 1000px;
  z-index: 3;
  opacity: 0;
}
input:hover {
  opacity: 1;
}
.item[data-item="-50,-50"] {
  --offset-x: -50;
  --offset-y: -50;
}
.item[data-item="-50,-49"] {
  --offset-x: -50;
  --offset-y: -49;
}
.item[data-item="-50,-48"] {
  --offset-x: -50;
  --offset-y: -48;
}
...

.viewer {
  position: absolute;
  inset: 0;
  z-index: 9;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 100px;
}
.viewer .frame {
  height: 100%;
  aspect-ratio: 1;
  border-radius: 32px;
  display: flex;
}
.viewer .enlarge {
  position: absolute;
  z-index: 1;
  border-radius: 32px;
  overflow: hidden;
  transition: opacity 300ms;
}
.viewer .enlarge img {
  width: 100%;
  object-fit: cover;
  width: 100%;
  height: 100%;
}
.viewer .scrim {
  position: absolute;
  inset: 0;
  background-color: var(--bg-scrim);
  pointer-events: none;
  opacity: 0;
  transition: opacity 300ms;
  backdrop-filter: blur(3px);
}
[data-enlarging=true] .scrim {
  opacity: 1;
  pointer-events: all;
}
@media (max-aspect-ratio: 1/1) {
  .viewer .frame {
    height: auto;
    width: 100%;
  }
}

3. The JavaScript handles all the user interaction, including drag/touch controls and the image enlargement. It depends on Hammer.js for gesture detection, so you’ll need to include that library. The script listens for pan events to update the sphere’s rotation and for click events on items to trigger the zoom-in effect.

const SphereApp = () => {
  const MAX_POLAR_ROT_DEG = 3;
  const PAN_SENSITIVTY = 18;
  const TRANSITION_DUR_MS = 300; // todo - use a transitionend event
  const DOM = {
    sphere: document.querySelector(".sphere"),
    main: document.querySelector("main"),
    items: document.querySelectorAll(".item__image"),
    frame: document.querySelector(".frame"),
    viewer: document.querySelector(".viewer"),
    scrim: document.querySelector(".scrim"),
  };
  const state = {
    rotation: { x: 0, y: 0 },
    startRotation: { x: 0, y: 0 },
    inertiaFrame: null,
    cancelTap: false,
  };
  const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
  const applyTransform = () => {
    DOM.sphere.style.transform = `translateZ(calc(var(--radius) * -1)) rotateX(${state.rotation.x}deg) rotateY(${state.rotation.y}deg)`;
  };
  const stopInertia = () => {
    if (state.inertiaFrame) {
      cancelAnimationFrame(state.inertiaFrame);
      state.inertiaFrame = null;
    }
  };
  const startInertia = (velocityX, velocityY) => {
    let vx = velocityX * 100;
    let vy = velocityY * 100;
    const friction = 0.92;
    const minVelocity = 0.1;
    const maxFrames = 120;
    let frameCount = 0;
    const step = () => {
      vx *= friction;
      vy *= friction;
      if (Math.abs(vx) < minVelocity && Math.abs(vy) < minVelocity) {
        state.inertiaFrame = null;
        return;
      }
      const proposedX = state.rotation.x - vy / 200;
      state.rotation.x = clamp(
        proposedX,
        -MAX_POLAR_ROT_DEG,
        MAX_POLAR_ROT_DEG
      );
      state.rotation.y += vx / 200;
      applyTransform();
      frameCount++;
      if (frameCount > maxFrames) {
        state.inertiaFrame = null;
        return;
      }
      state.inertiaFrame = requestAnimationFrame(step);
    };
    stopInertia();
    state.inertiaFrame = requestAnimationFrame(step);
  };
  const setupGestures = () => {
    const hammer = new Hammer(DOM.main);
    hammer.get("pan").set({ direction: Hammer.DIRECTION_ALL });
    hammer.on("panstart", () => {
      state.cancelTap = true;
      stopInertia();
      state.startRotation.x = state.rotation.x;
      state.startRotation.y = state.rotation.y;
    });
    hammer.on("panmove", ({ deltaX, deltaY }) => {
      const proposedX = state.startRotation.x - deltaY / PAN_SENSITIVTY;
      state.rotation.y = state.startRotation.y + deltaX / PAN_SENSITIVTY;
      state.rotation.x = clamp(
        proposedX,
        -MAX_POLAR_ROT_DEG,
        MAX_POLAR_ROT_DEG
      );
      applyTransform();
    });
    hammer.on("panend", ({ velocityX, velocityY }) => {
      setTimeout(() => (state.cancelTap = false), 100);
      startInertia(velocityX, velocityY);
    });
  };
  const setupTaps = () => {
    const getTransformRotation = (el) => {
      const str = el.style.transform;
      const matchX = str.match(/rotateX\((-?\d+(\.\d+)?)deg\)/);
      const matchY = str.match(/rotateY\((-?\d+(\.\d+)?)deg\)/);
      const rotateX = matchX ? parseFloat(matchX[1]) : 0;
      const rotateY = matchY ? parseFloat(matchY[1]) : 0;
      return { rotateX, rotateY };
    };
    const getRotationXY = (el) => {
      const style = window.getComputedStyle(el);
      const transform = style.transform;
      if (!transform || transform === "none") {
        return { rotateX: 0, rotateY: 0 };
      }
      if (!transform.startsWith("matrix3d")) {
        console.warn("Transform is not 3D. rotateX/Y won't be accurate.");
        return { rotateX: 0, rotateY: 0 };
      }
      const values = transform
        .match(/matrix3d\((.+)\)/)[1]
        .split(",")
        .map(parseFloat);
      const rotateX = Math.asin(-values[9]) * (180 / Math.PI);
      const rotateY = Math.atan2(values[8], values[10]) * (180 / Math.PI);
      return { rotateX, rotateY };
    };
    const handleClickScrim = () => {
      const el = document.querySelector('[data-focused="true"]');
      const parentEl = el.parentNode;
      const referenceDiv = document.querySelector(".item__image--reference");
      referenceDiv.remove();
      const enlargedImg = document.querySelector(".enlarge");
      enlargedImg.remove();
      parentEl.style.setProperty("--rot-y-delta", `0deg`);
      parentEl.style.setProperty("--rot-x-delta", `0deg`);
      el.style.transform = ``;
      el.style.zIndex = 0;
      setTimeout(() => {
        document.body.setAttribute("data-enlarging", "false");
        el.setAttribute("data-focused", "false");
      }, TRANSITION_DUR_MS);
    };
    const handleClick = (e) => {
      if (state.cancelTap) return;
      // .item__image
      const el = e.target;
      const parentEl = el.parentNode;
      el.setAttribute("data-focused", "true");
      const parentRotation = getRotationXY(parentEl);
      const globalRotation = getTransformRotation(DOM.sphere);
      const normalizeDegrees = (deg) => ((deg % 360) + 360) % 360;
      const parentY = normalizeDegrees(parentRotation.rotateY);
      const globalY = normalizeDegrees(globalRotation.rotateY);
      let rotY = -(parentY + globalY) % 360;
      if (rotY < -180) rotY += 360;
      parentEl.style.setProperty("--rot-y-delta", `${rotY}deg`);
      const rotX = -parentRotation.rotateX - globalRotation.rotateX;
      parentEl.style.setProperty("--rot-x-delta", `${rotX}deg`);
      const referenceDiv = document.createElement("div");
      parentEl.appendChild(referenceDiv);
      referenceDiv.style.opacity = 0;
      referenceDiv.classList.add("item__image", "item__image--reference");
      referenceDiv.style.transform = `rotateX(${-parentRotation.rotateX}deg) rotateY(${-parentRotation.rotateY}deg)`;
      const sourceRect = referenceDiv.getBoundingClientRect();
      const targetRect = DOM.frame.getBoundingClientRect();
      const deltaScaleX = targetRect.width / sourceRect.width;
      const deltaScaleY = targetRect.height / sourceRect.height;
      const deltaScale = Math.min(deltaScaleX, deltaScaleY);
      el.style.transform = `scale(${deltaScale}) translateZ(30px)`;
      el.style.zIndex = 3;
      const img = document.createElement("img");
      const newSrc = `${parentEl.getAttribute("data-src")}`;
      img.src = newSrc;
      // When the image loads, replace the src with a higher res version
      //https://assets.codepen.io/215059/photo-1589156191108-c762ff4b96ab_1.jpg
      img.addEventListener("load", () => {
        const newSrc = `https://images.unsplash.com${parentEl
          .getAttribute("data-src")
          .replace(".jpg", "")
          .replace("https://assets.codepen.io/215059", "")}?w=1200&h=1200&fit=crop`;
        img.src = newSrc;
      });
      DOM.scrim.addEventListener("click", handleClickScrim, { once: true });
      setTimeout(() => {
        const renderedRect = el.getBoundingClientRect();
        const enlargementEl = document.createElement("div");
        Object.assign(enlargementEl.style, {
          top: renderedRect.top - DOM.main.getBoundingClientRect().top + "px",
          left: renderedRect.left + "px",
          width: renderedRect.width + "px",
          height: renderedRect.height + "px",
          opacity: 0,
        });
        setTimeout(() => {
          enlargementEl.style.opacity = 1;
          document.body.setAttribute("data-enlarging", "true");
        }, TRANSITION_DUR_MS);
        enlargementEl.classList.add("enlarge");
        enlargementEl.appendChild(img);
        DOM.viewer.appendChild(enlargementEl);
      }, TRANSITION_DUR_MS);
    };
    DOM.items.forEach((el) => el.addEventListener("click", handleClick));
  };
  const init = () => {
    setupGestures();
    setupTaps();
  };
  return { init };
};
SphereApp().init();

FAQs:

Q: How many images can the sphere display without performance issues?
A: The library performs well with 50-100 images on modern devices. Performance depends more on image file sizes and device capabilities than the number of DOM elements. We recommend optimizing images to under 200KB each and testing on target devices to determine practical limits.

Q: Can I customize the sphere size and positioning grid?
A: Yes, modify the –radius, –segments-x, and –segments-y CSS variables to adjust sphere dimensions and grid density. Larger radius values create more subtle curvature, while more segments allow finer positioning control. Remember that segment changes require updating your data-item coordinates accordingly.

Q: Does it work on mobile devices and tablets?
A: The library includes full touch support through Hammer.js and responsive CSS. However, 3D transforms can be resource-intensive on older mobile devices.

Q: How do I handle different image aspect ratios?
A: Use the data-item-size attribute to specify how many grid units each image occupies. Square images work best with “2,2” sizing, while landscape images might use “3,2” or “4,2”. The CSS automatically scales images to fit their containers using object-fit: cover.

Q: How does the high-resolution image loading work?
A: The JavaScript is coded to fetch a higher-resolution version of an image when it’s enlarged. It does this by taking the data-src URL and modifying it to request a larger size from a specific image service (in the demo, it’s Unsplash). You’ll need to adapt this logic to match your own image hosting setup.

You Might Be Interested In:


Leave a Reply