Copied!
Hero

Photo story hero

A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Photo story hero" for my site using the Enrichr Library element at https://enrichr.dev/elements/photo-story-hero.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/photo-story-hero.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- headline: Capturing Corgis, Crafting Stories
- lead: Follow one small dog through bright colours, open fields and the best light of the day.
- auto: 1
- turns: 2
- tilt: 45
- smoothing: 1
- cards: 24
- background: #0c1e18
- clay: #172c25
- ink: #fbf8f1
- accent: #e6ff55

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/photo-story-hero.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-psh">
  <div class="ef-psh__grid" aria-hidden="true"></div>
  <div class="ef-psh__scroll" tabindex="0" role="region" aria-label="Photo story. Scroll inside it to turn the gallery">
    <header class="ef-psh__hero">
      <span class="ef-psh__badge">&#10022; A photo journal</span>
      <h2 class="ef-psh__h">Capturing Corgis, Crafting Stories</h2>
      <p class="ef-psh__lead">Follow one small dog through bright colours, open fields and the best light of the day.</p>
    </header>
    <section class="ef-psh__gallery" aria-label="Gallery">
      <div class="ef-psh__float" aria-live="polite">
        <h3 class="ef-psh__title">Orange Hour</h3>
        <p class="ef-psh__desc">A puppy against a bright orange wall, shot at eye level so the ears do all the talking. One clean colour, one curious face, and nothing else in the frame.</p>
      </div>
      <div class="ef-psh__scene" role="img" aria-label="A sphere of corgi photo cards that turns as you scroll, with the cards at the front in full colour">
        <div class="ef-psh__sphere"></div>
      </div>
      <svg class="ef-psh__lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path d="M0,50 Q25,30 50,50 T100,50" stroke="rgba(255,255,255,0.05)" stroke-width="0.2" fill="none"/><path d="M20,0 L80,100" stroke="rgba(255,255,255,0.03)" stroke-width="0.2" fill="none"/></svg>
    </section>
    <section class="ef-psh__journey" aria-label="Book a session">
      <div class="ef-psh__scatter" aria-hidden="true"></div>
      <div class="ef-psh__cta">
        <h3 class="ef-psh__ctah">Start Your Own Story</h3>
        <p class="ef-psh__ctap">Bring your dog. We will bring the light.</p>
        <a href="#" class="ef-psh__btn ef-psh__btn--chunky ef-psh__btn--big">Book a session</a>
      </div>
    </section>
  </div>
  <nav class="ef-psh__nav" aria-label="Main">
    <a href="#" class="ef-psh__logo"><svg viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg>Enrichr</a>
    <div class="ef-psh__links">
      <a href="#" class="ef-psh__link">Gallery</a>
      <a href="#" class="ef-psh__link">Stories</a>
      <a href="#" class="ef-psh__link">Log in</a>
      <a href="#" class="ef-psh__btn ef-psh__btn--chunky">Sign up</a>
    </div>
    <button type="button" class="ef-psh__btn ef-psh__menu">Menu <span aria-hidden="true">=</span></button>
  </nav>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-psh { --ef-psh-w: 1280px; --ef-psh-h: 720px; --ef-psh-k: 1; --ef-psh-bg: #0c1e18; --ef-psh-clay: #172c25; --ef-psh-ink: #fbf8f1; --ef-psh-acc: #e6ff55; --ef-psh-muted: color-mix(in srgb, var(--ef-psh-ink) 62%, var(--ef-psh-bg)); --ef-psh-cw: 160px; --ef-psh-ch: 220px; position: relative; isolation: isolate; width: 100%; min-height: 32rem; overflow: hidden; border-radius: 1rem; background: var(--ef-psh-bg); color: var(--ef-psh-ink); font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 16px; line-height: 1.5; text-align: left; }
.ef-psh .ef-psh__grid { position: absolute; inset: 0; z-index: 0; pointer-events: none; background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); background-size: 40px 40px; }
.ef-psh .ef-psh__scroll { position: absolute; inset: 0; z-index: 1; overflow-x: hidden; overflow-y: auto; outline: none; scrollbar-width: none; }
.ef-psh .ef-psh__scroll::-webkit-scrollbar { display: none; }
.ef-psh .ef-psh__scroll:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-psh-acc); border-radius: 1rem; }

/* nav: stays at the top of the box like the original's fixed bar */
.ef-psh .ef-psh__nav { position: absolute; top: 0; left: 0; right: 0; z-index: 3; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: calc(24px * var(--ef-psh-k)) calc(48px * var(--ef-psh-k)); background: linear-gradient(to bottom, color-mix(in srgb, var(--ef-psh-bg) 90%, transparent), transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); pointer-events: none; }
.ef-psh .ef-psh__nav > * { pointer-events: auto; }
.ef-psh .ef-psh__logo { display: flex; align-items: center; gap: 10px; color: var(--ef-psh-ink); text-decoration: none; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: 1.2rem; font-weight: 700; letter-spacing: -0.03em; white-space: nowrap; }
.ef-psh .ef-psh__logo svg { width: 26px; height: 26px; color: var(--ef-psh-acc); }
.ef-psh .ef-psh__links { display: flex; align-items: center; gap: 2rem; padding: 0.5rem 1rem 0.5rem 2rem; border-radius: 40px; background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.02); box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.9), inset 1px 1px 2px rgba(255, 255, 255, 0.04), inset -1px -1px 2px rgba(0, 0, 0, 0.6); }
.ef-psh .ef-psh__link { color: var(--ef-psh-ink); text-decoration: none; font-size: 0.9rem; transition: color 0.3s ease; }
.ef-psh .ef-psh__link:hover { color: var(--ef-psh-muted); }
.ef-psh .ef-psh__btn { display: inline-flex; align-items: center; gap: 8px; padding: 0.6rem 1.2rem; border: none; border-radius: 30px; font-family: inherit; font-size: 0.9rem; font-weight: 500; line-height: 1.2; text-decoration: none; cursor: pointer; color: var(--ef-psh-bg); background: var(--ef-psh-ink); transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; }
.ef-psh .ef-psh__btn--chunky { color: var(--ef-psh-bg); background: var(--ef-psh-acc); box-shadow: 0 4px 0 color-mix(in srgb, var(--ef-psh-acc) 45%, #000), 0 6px 10px rgba(0, 0, 0, 0.4), inset 0 -2px 5px rgba(0, 0, 0, 0.1), inset 0 2px 5px rgba(255, 255, 255, 0.8); }
.ef-psh .ef-psh__btn--chunky:active { transform: translateY(4px); box-shadow: 0 0 0 color-mix(in srgb, var(--ef-psh-acc) 45%, #000), 0 2px 5px rgba(0, 0, 0, 0.4), inset 0 -1px 2px rgba(0, 0, 0, 0.1); }
.ef-psh .ef-psh__menu { color: var(--ef-psh-ink); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 4px 0 #050d0a, 0 6px 10px rgba(0, 0, 0, 0.6), inset 1px 1px 2px rgba(255, 255, 255, 0.04); }
.ef-psh .ef-psh__btn:focus-visible, .ef-psh .ef-psh__link:focus-visible, .ef-psh .ef-psh__logo:focus-visible { outline: 3px solid var(--ef-psh-acc); outline-offset: 3px; }

/* hero copy */
.ef-psh .ef-psh__hero { position: relative; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: calc(24px * var(--ef-psh-k)); padding: calc(192px * var(--ef-psh-k)) 2rem calc(64px * var(--ef-psh-k)); text-align: center; }
.ef-psh .ef-psh__badge { display: inline-block; padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.8rem; line-height: 1.5; color: var(--ef-psh-muted); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.9), inset 2px 2px 4px rgba(255, 255, 255, 0.04), inset -2px -2px 4px rgba(0, 0, 0, 0.6); }
.ef-psh .ef-psh__h { max-width: 800px; margin: 0; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.7rem, calc(var(--ef-psh-w) * 0.05), 4.5rem); font-weight: 500; line-height: 1.4; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; text-transform: none; }
.ef-psh .ef-psh__lead { max-width: 500px; margin: 0; font-size: 1rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }

/* the pinned gallery: three box heights of scroll with the scene stuck in view */
.ef-psh .ef-psh__gallery { position: relative; height: calc(var(--ef-psh-h) * 3); width: 100%; }
.ef-psh .ef-psh__float { position: sticky; top: 50%; z-index: 10; width: min(300px, 40%); margin-left: 10%; transform: translateY(-50%); }
.ef-psh .ef-psh__title { margin: 0 0 1rem; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.15rem, calc(32px * var(--ef-psh-k)), 2rem); font-weight: 500; line-height: 1.5; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; text-shadow: 0 4px 10px rgba(0, 0, 0, 0.8); }
.ef-psh .ef-psh__desc { margin: 0; font-size: 0.9rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }
.ef-psh .ef-psh__scene { position: sticky; top: 0; height: var(--ef-psh-h); width: 100%; display: flex; justify-content: center; align-items: center; perspective: calc(1200px * var(--ef-psh-k)); overflow: hidden; }
.ef-psh .ef-psh__sphere { position: relative; width: 0; height: 0; transform-style: preserve-3d; }
.ef-psh .ef-psh__card { position: absolute; box-sizing: border-box; width: var(--ef-psh-cw); height: var(--ef-psh-ch); left: calc(var(--ef-psh-cw) / -2); top: calc(var(--ef-psh-ch) / -2); padding: calc(8px * var(--ef-psh-k)); border-radius: calc(16px * var(--ef-psh-k)); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.03); transform-style: preserve-3d; backface-visibility: visible; box-shadow: 8px 8px 16px rgba(0, 0, 0, 0.9), -4px -4px 10px rgba(255, 255, 255, 0.02), inset 3px 3px 6px rgba(255, 255, 255, 0.04), inset -3px -3px 6px rgba(0, 0, 0, 0.6); transition: filter 0.4s ease; }
.ef-psh .ef-psh__img { display: block; width: 100%; height: 100%; max-width: none; margin: 0; object-fit: cover; border-radius: calc(10px * var(--ef-psh-k)); filter: grayscale(80%) brightness(0.6); transition: all 0.4s ease; user-select: none; pointer-events: none; }
.ef-psh .ef-psh__card.is-active { filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1)); }
.ef-psh .ef-psh__card.is-active .ef-psh__img { filter: grayscale(0%) brightness(1); }
.ef-psh .ef-psh__lines { position: absolute; top: 0; left: 0; z-index: -1; width: 100%; height: 100%; pointer-events: none; }

/* closing section with scattered cards drifting up */
.ef-psh .ef-psh__journey { position: relative; box-sizing: border-box; min-height: var(--ef-psh-h); display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; overflow: hidden; background: radial-gradient(circle at center, color-mix(in srgb, var(--ef-psh-bg) 95%, var(--ef-psh-ink)) 0%, var(--ef-psh-bg) 70%); }
.ef-psh .ef-psh__scatter { position: absolute; inset: 0; pointer-events: none; }
.ef-psh .ef-psh__scatter .ef-psh__card { width: calc(100px * var(--ef-psh-k)); height: calc(140px * var(--ef-psh-k)); opacity: 0.5; }
.ef-psh .ef-psh__cta { position: relative; z-index: 2; box-sizing: border-box; max-width: 100%; padding: calc(48px * var(--ef-psh-k)); text-align: center; border-radius: 24px; background: color-mix(in srgb, var(--ef-psh-bg) 60%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); }
.ef-psh .ef-psh__ctah { margin: 0 0 0.5rem; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.5rem, calc(48px * var(--ef-psh-k)), 3rem); font-weight: 500; line-height: 1.3; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; }
.ef-psh .ef-psh__ctap { margin: 0 0 2rem; font-size: 1rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }
.ef-psh .ef-psh__btn--big { font-size: 1.1rem; padding: 1rem 2.5rem; border-radius: 40px; }

/* narrow boxes, like the original's 768px breakpoint */
.ef-psh.ef-psh--narrow .ef-psh__links { display: none; }
.ef-psh.ef-psh--narrow .ef-psh__h { font-size: clamp(1.5rem, calc(var(--ef-psh-w) * 0.075), 2.5rem); }
.ef-psh.ef-psh--narrow .ef-psh__float { position: absolute; top: 80%; margin-left: 5%; width: 90%; text-align: center; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Photo story hero: a dark photography hero (nav, badge, centred headline) that scrolls into a pinned 3D gallery. Photo
// cards sit on a sphere laid out with the Fibonacci (golden angle) spiral, each turned to face outward, in a CSS 3D scene.
// While the gallery section is pinned for two box heights of scroll, the sphere turns two full times around its vertical
// axis and tilts 45 degrees, with 1 s of scrub smoothing (an expo.out tween restarted toward the scroll position on every
// update, like GSAP ScrollTrigger's scrub: 1). The cards nearest the scroll position light up in full colour, and the story
// text beside the sphere cross-fades (0.2 s out, 0.2 s in) through four captions. A closing section has scattered cards
// drifting up with a staggered parallax. Everything lives in the element's own scroller, so it works inside any box.
// Autoplay scrolls it down and back up on its own until someone scrolls, touches or hovers it. Reduced motion holds the
// sphere still at one angle, swaps the captions without fading, drops the parallax and turns autoplay off.
// The loop pauses off screen and in hidden tabs.
// mount(root, { headline, lead, auto ("1" on, "0" off), turns (full turns over the gallery), tilt (deg), smoothing (s),
//               cards (number on the sphere), background, clay (card and pill colour), ink (text), accent (buttons, logo) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-psh");
  var scroller = root.querySelector(".ef-psh__scroll");
  var gallery = root.querySelector(".ef-psh__gallery");
  var sphere = root.querySelector(".ef-psh__sphere");
  var journey = root.querySelector(".ef-psh__journey");
  var scatterBox = root.querySelector(".ef-psh__scatter");
  var floatEl = root.querySelector(".ef-psh__float");
  var titleEl = root.querySelector(".ef-psh__title");
  var descEl = root.querySelector(".ef-psh__desc");
  var headEl = root.querySelector(".ef-psh__h");
  var leadEl = root.querySelector(".ef-psh__lead");
  var IMGS = ["/img/library/corgi-1.jpg", "/img/library/corgi-2.jpg", "/img/library/corgi-3.jpg", "/img/library/corgi-4.jpg", "/img/library/corgi-5.jpg", "/img/library/corgi-6.jpg"];
  var CROPS = ["50% 40%", "35% 50%", "65% 55%", "50% 75%"]; // repeats of a photo show a different part of it
  var STORIES = [
    ["Orange Hour", "A puppy against a bright orange wall, shot at eye level so the ears do all the talking."],
    ["Field of Flowers", "Wildflowers to the horizon and a dog who sat still for exactly one frame."],
    ["Autumn Lookout", "Up on a rock among the turning leaves, keeping watch over the whole trail."],
    ["Road Trip", "A corgi and a friend mid-stride on a dirt road, with nothing ahead but dust and warm light."]
  ];
  var SCATTER = [["10%", "15%"], ["20%", "80%"], ["60%", "10%"], ["75%", "85%"], ["85%", "30%"], ["15%", "50%"]];
  var opts = {}, cards = [], scat = [], W = 1, H = 1, k = 1, narrow = false, base = null;
  var rot = { from: 0, to: 0, cur: 0, t: 0 }, lastRaw = -1, lastQ = -1, focus = -99, drawnRot = -1, shown = 0, want = 0, fade = null;
  var raf = 0, last = 0, visible = true, destroyed = false, io = null, ro = null, offs = [], hold = 0, auto = null, built = 0;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function now() { return performance.now(); }
  function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
  function rand(i, salt) { var h = Math.imul(i + 1 + salt * 7919, 374761393) ^ Math.imul(salt + 13, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }
  function expoOut(p) { return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p); } // ScrollTrigger's scrub ease
  function sineInOut(p) { return (1 - Math.cos(Math.PI * p)) / 2; }
  function card(src, crop) {
    var c = document.createElement("div"); c.className = "ef-psh__card";
    var im = document.createElement("img"); im.className = "ef-psh__img"; im.src = src; im.alt = ""; im.decoding = "async"; im.draggable = false;
    im.style.objectPosition = crop;
    c.appendChild(im);
    return c;
  }
  function build() {
    if (built === opts.cards) return;
    built = opts.cards;
    sphere.textContent = ""; cards = [];
    for (var i = 0; i < opts.cards; i++) {
      var c = card(IMGS[i % IMGS.length], CROPS[Math.floor(i / IMGS.length) % CROPS.length]);
      sphere.appendChild(c); cards.push(c);
    }
    focus = -99; lastRaw = -1;
  }
  function buildScatter() {
    scatterBox.textContent = ""; scat = [];
    SCATTER.forEach(function (pos, i) {
      var c = card(IMGS[i % IMGS.length], "50% 45%");
      c.style.top = pos[0]; c.style.left = pos[1];
      scatterBox.appendChild(c);
      scat.push({ el: c, rot: (rand(i, 3) - 0.5) * 40 });
    });
  }
  // golden-angle spiral on a sphere; each card turned so it faces out from the centre
  function place() {
    var n = cards.length, r = base.r * k;
    cards.forEach(function (c, i) {
      var phi = Math.acos(1 - 2 * (i + 0.5) / n), theta = Math.PI * (1 + Math.sqrt(5)) * i;
      var x = r * Math.cos(theta) * Math.sin(phi), y = r * Math.sin(theta) * Math.sin(phi), z = r * Math.cos(phi);
      var ry = Math.atan2(x, z) * 180 / Math.PI, rx = Math.asin(-y / r) * 180 / Math.PI;
      c.style.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px," + z.toFixed(2) + "px) rotateY(" + ry.toFixed(3) + "deg) rotateX(" + rx.toFixed(3) + "deg)";
    });
  }
  // the original sized everything for a 720 px tall window (radius 380, cards 160 x 220) and switched to a smaller sphere
  // under 768 px wide; here the box is the window, so the same numbers are scaled by the box size
  function size() {
    W = Math.max(1, box.clientWidth); H = Math.max(1, box.clientHeight);
    narrow = W < 768;
    base = narrow ? { r: 200, cw: 120, ch: 160 } : { r: 380, cw: 160, ch: 220 };
    k = Math.max(0.3, Math.min(1.3, Math.min(H / 720, W / (narrow ? 400 : 1100))));
    box.classList.toggle("ef-psh--narrow", narrow);
    box.style.setProperty("--ef-psh-w", W + "px");
    box.style.setProperty("--ef-psh-h", H + "px");
    box.style.setProperty("--ef-psh-k", k.toFixed(4));
    box.style.setProperty("--ef-psh-cw", (base.cw * k).toFixed(2) + "px");
    box.style.setProperty("--ef-psh-ch", (base.ch * k).toFixed(2) + "px");
    place();
    lastRaw = -1; lastQ = -1; drawnRot = -1;
  }
  function galleryProgress() { // trigger the gallery, start "top top", end "bottom bottom"
    var span = gallery.offsetHeight - scroller.clientHeight;
    return span > 0 ? clamp((scroller.scrollTop - gallery.offsetTop) / span) : 0;
  }
  function journeyProgress() { // start "top bottom", end "bottom top"
    var span = journey.offsetHeight + scroller.clientHeight;
    return clamp((scroller.scrollTop + scroller.clientHeight - journey.offsetTop) / span);
  }
  function drawSphere(t) {
    var d = opts.smoothing * 1000;
    rot.cur = reduce ? 0.15 : d > 0 ? rot.from + (rot.to - rot.from) * expoOut((t - rot.t) / d) : rot.to;
    if (Math.abs(rot.cur - drawnRot) < 0.00005) return;
    drawnRot = rot.cur;
    sphere.style.transform = "rotateY(" + (opts.turns * 360 * rot.cur).toFixed(3) + "deg) rotateX(" + (opts.tilt * rot.cur).toFixed(3) + "deg)";
  }
  function setText(i) { titleEl.textContent = STORIES[i][0]; descEl.textContent = STORIES[i][1]; }
  function setOpacity(v) { titleEl.style.opacity = descEl.style.opacity = String(v); }
  // 0.2 s fade out (power1.out), swap to the newest caption, 0.2 s fade in; a change mid-fade-in fades out from where it is
  function stepFade(t) {
    if (reduce) { if (want !== shown) { shown = want; setText(shown); } return; }
    if (!fade) { if (want === shown) return; fade = { out: true, t: t, from: 1 }; }
    else if (!fade.out && want !== shown) fade = { out: true, t: t, from: Number(titleEl.style.opacity || 1) };
    var p = Math.min(1, (t - fade.t) / 200), e = 1 - (1 - p) * (1 - p);
    if (fade.out) {
      setOpacity(fade.from * (1 - e));
      if (p >= 1) { shown = want; setText(shown); fade = { out: false, t: t }; }
    } else {
      setOpacity(e);
      if (p >= 1) fade = null;
    }
  }
  function onScroll(t) {
    var raw = galleryProgress();
    if (raw !== lastRaw) {
      lastRaw = raw;
      rot.from = rot.cur; rot.to = raw; rot.t = t;
      want = Math.floor(raw * STORIES.length) % STORIES.length; // wraps back to the first caption at the very end, as before
      var f = Math.floor(raw * cards.length);
      if (f !== focus) { focus = f; cards.forEach(function (c, i) { c.classList.toggle("is-active", Math.abs(i - f) < 2); }); }
    }
    var q = reduce ? 0 : journeyProgress();
    if (q !== lastQ) {
      lastQ = q;
      var total = 0.5 + 0.1 * (scat.length - 1); // stagger 0.1 on 0.5 s tweens, scrubbed straight to the scroll
      scat.forEach(function (s, i) {
        var local = clamp((q * total - 0.1 * i) / 0.5);
        s.el.style.transform = "translate3d(0," + (-100 * k * local).toFixed(2) + "px,0) rotate(" + s.rot.toFixed(2) + "deg) scale(0.8)";
      });
    }
  }

  // autoplay: down through the gallery to the end, pause, back to the top, pause, again
  function autoOn() { return !reduce && String(opts.auto) !== "0"; }
  function autoStep(t) {
    var max = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
    if (!auto) { var at = scroller.scrollTop, down = at < max * 0.5; auto = { from: at, to: down ? max : 0, t: t + 1500, d: down ? 14000 : 3500 }; }
    var p = (t - auto.t) / auto.d;
    if (p < 0) return;
    scroller.scrollTop = auto.from + (auto.to - auto.from) * sineInOut(Math.min(1, p));
    if (p >= 1) { var back = auto.to > 0; auto = { from: auto.to, to: back ? 0 : max, t: t + (back ? 2000 : 1500), d: back ? 3500 : 14000 }; }
  }
  function pause(ms) { hold = Math.max(hold, now() + ms); auto = null; }

  function frame(t) {
    drawSphere(t);   // render the scrub first, then restart it toward the new scroll position (GSAP's ticker order)
    onScroll(t);
    stepFade(t);
  }
  function loop(t) {
    raf = requestAnimationFrame(loop);
    last = t;
    if (!visible || document.hidden) return;
    if (autoOn() && t > hold) autoStep(t);
    frame(t);
  }

  function on(el, type, fn, opt) { el.addEventListener(type, fn, opt); offs.push(function () { el.removeEventListener(type, fn, opt); }); }
  function onUser() { pause(4000); }

  function update(next) {
    opts = Object.assign({ headline: "Capturing Corgis, Crafting Stories", lead: "Follow one small dog through bright colours, open fields and the best light of the day.", auto: "1", turns: 2, tilt: 45, smoothing: 1, cards: 24, background: "#0c1e18", clay: "#172c25", ink: "#fbf8f1", accent: "#e6ff55" }, opts, next || {});
    opts.turns = num(opts.turns, 2); opts.tilt = num(opts.tilt, 45); opts.smoothing = Math.max(0, num(opts.smoothing, 1));
    opts.cards = Math.max(6, Math.min(60, Math.round(num(opts.cards, 24))));
    box.style.setProperty("--ef-psh-bg", opts.background);
    box.style.setProperty("--ef-psh-clay", opts.clay);
    box.style.setProperty("--ef-psh-ink", opts.ink);
    box.style.setProperty("--ef-psh-acc", opts.accent);
    headEl.textContent = opts.headline; leadEl.textContent = opts.lead;
    headEl.hidden = !opts.headline; leadEl.hidden = !opts.lead;
    if (destroyed) return;
    build();
    if (base) place();
    drawnRot = -1; lastRaw = -1;
    frame(now());
  }

  buildScatter();
  update(o);
  size();
  frame(now());
  on(box, "click", function (e) { var a = e.target.closest && e.target.closest('a[href="#"]'); if (a) e.preventDefault(); });
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; if (box.clientWidth !== W || box.clientHeight !== H) { size(); frame(now()); } }); ro.observe(box); }
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
  if (reduce) {
    // nothing moves on its own: just follow the scroll for the captions and the highlighted cards
    on(scroller, "scroll", function () { if (!destroyed) frame(now()); }, { passive: true });
  } else {
    ["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (t) { on(scroller, t, onUser, { passive: true }); });
    on(scroller, "pointerenter", function (e) { if (e.pointerType === "mouse") pause(1e9); });
    on(scroller, "pointerleave", function (e) { if (e.pointerType === "mouse") hold = now() + 2000; });
    last = now(); raf = requestAnimationFrame(loop);
  }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      offs.forEach(function (f) { f(); }); offs = [];
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"headline":"Capturing Corgis, Crafting Stories","lead":"Follow one small dog through bright colours, open fields and the best light of the day.","auto":1,"turns":2,"tilt":45,"smoothing":1,"cards":24,"background":"#0c1e18","clay":"#172c25","ink":"#fbf8f1","accent":"#e6ff55"});

How it works

The hero, the gallery and a closing section sit in the element's own scroller, with the nav fixed at the top of the box. The photo cards are placed on a sphere with the Fibonacci spiral: card i of n sits at the polar angle acos(1 - 2(i + 0.5) / n) and turns by the golden angle each step, and each card is rotated with atan2 and asin so it faces outward. The sphere lives in a CSS 3D scene with perspective, so there is no WebGL. The gallery section is three box heights tall and its scene is sticky, so it stays in view for two box heights of scroll. Over that stretch the sphere turns two full times around its vertical axis and tilts 45 degrees, smoothed the way GSAP ScrollTrigger's scrub: 1 does it, by restarting a one second expo.out tween toward the scroll position on every update. The three cards nearest the scroll position drop their grey filter, and the caption beside the sphere fades out for 0.2 seconds, changes, and fades back in as you pass each quarter. In the closing section six scattered cards drift up 100 px with a 0.1 stagger. The original's sizes (a 380 px radius and 160 by 220 px cards for a 720 px tall window, a smaller sphere under 768 px wide) are scaled to the box. Autoplay scrolls down and back so a preview moves on its own.

Accessibility and performance

The headline, captions and buttons are real text and links, and the scroller is a focusable region that arrow keys and Page Down scroll. The sphere is one labelled image for screen readers, and caption changes are announced politely. With prefers-reduced-motion the sphere holds still at one angle, captions change without fading, the scattered cards do not drift and autoplay is off. Autoplay stops as soon as you scroll, touch, press a key or hover.

Only one transform, on the sphere, changes while it turns; each card's own position is set once and again only after a resize. Card highlights change a class, not inline styles, and nothing is written when the scroll has not moved. Six photos are reused across all cards, so the browser decodes each once. The loop pauses off screen and in hidden tabs, and with reduced motion there is no loop at all, only a scroll listener.

SectionHero
TechniquesScroll-driven, CSS 3D, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byCapturing moments gallery hero by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Team card fan heroA "Small team, big results" hero where a fanned row of photo cards drops in, floats, tilts under the pointer and fans out as you scroll away. Scroll column modesA grid of columns that react to scroll in three ways: drifting parallax, side columns that lean outward, and tiles that fold up out of 3D. Vanilla port, no GSAP. Folding layout 3DA stack of panels hinged like a concertina that folds and unfolds in CSS 3D with scroll or a slider.

FAQ

Does it need GSAP or three.js?

No. The sphere is plain CSS 3D transforms, and the scroll link is a few lines: read how far the gallery has scrolled, ease toward it with the same one second expo.out curve ScrollTrigger's scrub uses, and write one rotateY and rotateX.

Can it use the page scroll instead of its own scroller?

Yes. Put the gallery section in your page, keep its scene position: sticky, and read progress from the section's getBoundingClientRect instead of the scroller's scrollTop. The inner scroller is used here so the hero works inside any box, including a preview panel.

How do I use my own photos and captions?

Change the IMGS list and the STORIES list at the top of the script. Six photos are repeated across the cards with different crops. Photos: Alvan Nee and fatty corgi on Unsplash.

Why does it scroll on its own?

That is autoplay, so a preview shows the sphere turning without anyone scrolling. It stops when you interact. Set Autoplay to Off for a hero that only moves when scrolled.