bottega

sections

Scroll Sphere Gallery

A scroll-driven 3D photo sphere. Images are distributed over a sphere via the Fibonacci lattice; a sticky scene stays pinned while GSAP + ScrollTrigger scrubs the sphere's rotation, the nearest card un-grays and glows, and a side caption fades in/out. CSS 3D transforms (no WebGL).

Ported from Deckard (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/scroll-sphere-gallery

Usage

Usagetsx
<ScrollSphereGallery images={[]} />

Props

PropTypeDefaultDescription
images*{ src: string; alt?: string; title?: string; desc?: string; }[]
radiusnumber380
rotationsnumber2
tiltnumber45
scrollLengthstring300vh

Source

scroll-sphere-gallery.tsxtsx
// registry/sections/scroll-sphere-gallery/scroll-sphere-gallery.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { placeOnSphere } from "./fibonacci";
import styles from "./scroll-sphere-gallery.module.css";

// Register plugin once (module-scoped, client-only guard)
let pluginRegistered = false;

export interface ScrollSphereGalleryProps {
  images: { src: string; alt?: string; title?: string; desc?: string }[];
  radius?: number;
  rotations?: number;
  tilt?: number;
  scrollLength?: string;
}

export default function ScrollSphereGallery({
  images,
  radius = 380,
  rotations = 2,
  tilt = 45,
  scrollLength = "300vh",
}: ScrollSphereGalleryProps) {
  const rootRef = useRef<HTMLElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const sphereRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLHeadingElement>(null);
  const descRef = useRef<HTMLParagraphElement>(null);

  // Responsive radius: SSR uses prop; client patches after mount
  const [r, setR] = useState(radius);
  const [activeIndex, setActiveIndex] = useState(0);
  // Mount gate: no server/client mismatch on initial render
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    setR(window.innerWidth < 768 ? Math.min(200, radius) : radius);
  }, [radius]);

  // Compute Fibonacci positions (deterministic — same formula as fibonacci.ts)
  const points = placeOnSphere(images.length, r);
  const r3 = (n: number) => Math.round(n * 1000) / 1000;

  useEffect(() => {
    if (!mounted) return;

    if (!pluginRegistered) {
      gsap.registerPlugin(ScrollTrigger);
      pluginRegistered = true;
    }

    const container = containerRef.current;
    const sphere = sphereRef.current;
    if (!sphere || !container) return;

    const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // Set initial caption
    const firstWithTitle = images.find((img) => img.title);
    if (titleRef.current) titleRef.current.textContent = firstWithTitle?.title ?? "";
    if (descRef.current) descRef.current.textContent = firstWithTitle?.desc ?? "";

    if (prefersReduced) {
      // Collapse sticky scroll height — post-mount, SSR-safe
      container.style.height = "100vh";
      return () => {
        container.style.height = "";
      };
    }

    let lastTextIndex = -1;
    let disposed = false;

    const titledImages = images.filter((img) => img.title);

    const ctx = gsap.context(() => {
      function updateActiveCard(progress: number) {
        const textIndex = Math.min(
          titledImages.length - 1,
          Math.floor(progress * titledImages.length)
        );
        const activeIdx = Math.min(images.length - 1, Math.floor(progress * images.length));

        // Update active card via React state
        setActiveIndex(activeIdx);

        // Caption swap with fade
        const titleEl = titleRef.current;
        const descEl = descRef.current;
        if (titleEl && descEl && textIndex !== lastTextIndex) {
          lastTextIndex = textIndex;
          gsap.to([titleEl, descEl], {
            opacity: 0,
            duration: 0.2,
            onComplete: () => {
              if (disposed) return;
              titleEl.textContent = titledImages[textIndex]?.title ?? "";
              descEl.textContent = titledImages[textIndex]?.desc ?? "";
              gsap.to([titleEl, descEl], { opacity: 1, duration: 0.2 });
            },
          });
        }
      }

      gsap.to(sphere, {
        rotateY: 360 * rotations,
        rotateX: tilt,
        ease: "none",
        scrollTrigger: {
          trigger: container,
          start: "top top",
          end: "bottom bottom",
          scrub: 1,
          onUpdate: (self) => updateActiveCard(self.progress),
        },
      });
    }, rootRef);

    return () => {
      disposed = true;
      ctx.revert();
      container.style.height = "";
    };
  }, [mounted, images, rotations, tilt]);

  return (
    <section
      ref={rootRef}
      className={styles.container}
      style={{ height: scrollLength }}
      aria-label="Scroll sphere gallery"
    >
      {/* Network-lines SVG overlay */}
      <svg
        aria-hidden="true"
        className={styles.networkLines}
      >
        <defs>
          <pattern
            id="ssg-net"
            x="0"
            y="0"
            width="120"
            height="120"
            patternUnits="userSpaceOnUse"
          >
            <line x1="0" y1="60" x2="120" y2="60" stroke="currentColor" strokeWidth="0.5" />
            <line x1="60" y1="0" x2="60" y2="120" stroke="currentColor" strokeWidth="0.5" />
            <circle cx="60" cy="60" r="2" fill="currentColor" />
            <circle cx="0" cy="0" r="1.5" fill="currentColor" />
            <circle cx="120" cy="0" r="1.5" fill="currentColor" />
            <circle cx="0" cy="120" r="1.5" fill="currentColor" />
            <circle cx="120" cy="120" r="1.5" fill="currentColor" />
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill="url(#ssg-net)" />
      </svg>

      {/* Sticky viewport */}
      <div className={styles.scene}>
        {/* Floating caption panel */}
        <div className={styles.floatingCaption}>
          <div className={styles.captionCard}>
            <div className={styles.captionAccent} aria-hidden="true" />
            <h2 ref={titleRef} className={styles.captionTitle}>
              {images.find((img) => img.title)?.title ?? ""}
            </h2>
            <p ref={descRef} className={styles.captionDesc}>
              {images.find((img) => img.title)?.desc ?? ""}
            </p>
          </div>
        </div>

        {/* 3D Sphere */}
        <div
          className={styles.sphere}
          ref={sphereRef}
        >
          {images.map((img, i) => {
            const p = points[i];
            const isActive = mounted && i === activeIndex;
            return (
              <div
                key={i}
                className={`${styles.card} ${isActive ? styles.cardActive : ""}`}
                style={{
                  transform: `translate3d(${r3(p.x)}px, ${r3(p.y)}px, ${r3(p.z)}px) rotateY(${r3(p.rotY)}deg) rotateX(${r3(p.rotX)}deg)`,
                }}
              >
                <img src={img.src} alt={img.alt ?? ""} loading="lazy" />
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Dependencies

  • gsap
  • @bottega/tokens