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)
Running Through Reflection
A rainy-day running track — a sheet of water turns a sprinting silhouette into an abstract study of movement.












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-galleryUsage
Usagetsx
<ScrollSphereGallery images={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images* | { src: string; alt?: string; title?: string; desc?: string; }[] | — | |
| radius | number | 380 | |
| rotations | number | 2 | |
| tilt | number | 45 | |
| scrollLength | string | 300vh |
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