Horizontal Scroll Gallery

A vertically-scrolled section that pins a viewport and glides five gradient panels sideways, driven entirely by a CSS scroll-timeline. Where scroll-driven animations aren't supported it degrades to a snap-scrolling row — no JS either way.

  • animation-timeline: scroll()
  • position: sticky
  • scroll-snap

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

scroll/horizontal-gallery.astro
---
export const meta = {
  title: "Horizontal Scroll Gallery",
  tags: ["scroll", "sticky", "gallery"],
  description:
    "A vertically-scrolled section that pins a viewport and glides five gradient panels sideways, driven entirely by a CSS scroll-timeline. Where scroll-driven animations aren't supported it degrades to a snap-scrolling row — no JS either way.",
  tech: ["animation-timeline: scroll()", "position: sticky", "scroll-snap"],
  height: 600,
};

const panels = [
  { n: "01", title: "Refraction", body: "White light splits into its spectrum the moment it changes medium.", a: "--spectrum-1", b: "--spectrum-2" },
  { n: "02", title: "Dispersion", body: "Each wavelength bends by a different angle — order out of interference.", a: "--spectrum-2", b: "--spectrum-3" },
  { n: "03", title: "Total Internal", body: "Past the critical angle, light stops escaping and reflects entirely.", a: "--spectrum-3", b: "--spectrum-4" },
  { n: "04", title: "Diffraction", body: "Edges and slits bend waves into rings of luminous interference.", a: "--spectrum-4", b: "--spectrum-5" },
  { n: "05", title: "Recombination", body: "Collapse the spectrum back through a lens and white returns.", a: "--spectrum-5", b: "--spectrum-6" },
];
---

<section class="hgal">
  <div class="hgal__sticky">
    <header class="hgal__head">
      <p class="hgal__kicker">The Prism Series</p>
      <p class="hgal__hint" aria-hidden="true">scroll ↓</p>
    </header>
    <ul class="hgal__track">
      {panels.map((p) => (
        <li
          class="hgal__panel"
          style={`--c-a: var(${p.a}, #8b5cf6); --c-b: var(${p.b}, #45d3e8);`}
        >
          <span class="hgal__num">{p.n}</span>
          <div class="hgal__caption">
            <h3>{p.title}</h3>
            <p>{p.body}</p>
          </div>
        </li>
      ))}
    </ul>
  </div>
</section>

<style>
  .hgal {
    background: var(--bg-sunk, #0b0c15);
    color: var(--ink, #f2f2f7);
  }

  /* ---- Base / fallback: a plain horizontal snap scroller ---- */
  .hgal__sticky {
    padding: clamp(1.5rem, 4vw, 3rem) 0;
    display: grid;
    gap: 1.5rem;
  }
  .hgal__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    padding: 0 clamp(1.25rem, 4vw, 3rem);
  }
  .hgal__kicker {
    margin: 0;
    font-family: var(--font-mono, monospace);
    font-size: var(--step--1, 0.85rem);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--ink-muted, #b7b9c9);
  }
  .hgal__hint {
    margin: 0;
    font-size: var(--step--1, 0.85rem);
    color: var(--ink-faint, #8a8c9e);
    animation: hgal-bob 1.8s var(--ease-in-out, ease-in-out) infinite;
  }
  @keyframes hgal-bob { 50% { transform: translateY(3px); opacity: 0.6; } }

  .hgal__track {
    list-style: none;
    margin: 0;
    display: flex;
    gap: clamp(1rem, 2vw, 1.75rem);
    padding: 0 clamp(1.25rem, 4vw, 3rem);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: thin;
  }
  .hgal__panel {
    scroll-snap-align: center;
    flex: 0 0 min(82%, 30rem);
    position: relative;
    aspect-ratio: 4 / 5;
    max-height: 26rem;
    border-radius: var(--radius-l, 22px);
    padding: clamp(1.25rem, 3vw, 2rem);
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    overflow: hidden;
    isolation: isolate;
    color: #fff;
    background:
      radial-gradient(120% 120% at 12% 8%, color-mix(in oklab, var(--c-a) 85%, transparent), transparent 60%),
      linear-gradient(150deg, var(--c-a), var(--c-b));
    box-shadow: var(--shadow-l, 0 20px 60px rgba(0,0,0,0.35));
  }
  .hgal__panel::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: radial-gradient(80% 60% at 85% 100%, rgba(255,255,255,0.28), transparent 55%);
    mix-blend-mode: soft-light;
  }
  .hgal__num {
    font-family: var(--font-mono, monospace);
    font-size: var(--step-2, 1.6rem);
    font-weight: 700;
    letter-spacing: -0.02em;
    text-shadow: 0 2px 20px rgba(0,0,0,0.3);
  }
  .hgal__caption h3 {
    margin: 0 0 0.4rem;
    font-size: var(--step-4, 2.2rem);
    line-height: 1.05;
    letter-spacing: -0.03em;
    text-wrap: balance;
  }
  .hgal__caption p {
    margin: 0;
    max-width: 26ch;
    line-height: 1.5;
    color: rgba(255, 255, 255, 0.86);
  }

  /* ---- Enhanced: scroll-driven pinned horizontal scroll ---- */
  @supports (animation-timeline: scroll()) {
    .hgal {
      height: 360vh;              /* scroll length that feeds the timeline */
      position: relative;
    }
    .hgal__sticky {
      position: sticky;
      top: 0;
      height: 100vh;
      align-content: center;
      overflow: hidden;
    }
    .hgal__track {
      width: max-content;
      overflow: visible;
      scroll-snap-type: none;
      align-items: center;
      animation: hgal-slide linear both;
      animation-timeline: scroll();  /* nearest block scroller = the frame */
      will-change: transform;
    }
    .hgal__panel {
      flex-basis: min(78vw, 34rem);
      max-height: min(66vh, 30rem);
    }
    .hgal__hint { display: none; }
  }
  @keyframes hgal-slide {
    to { transform: translateX(calc(-100% + 100vw)); }
  }

  /* Respect reduced motion: drop the pin, keep the snap scroller */
  @media (prefers-reduced-motion: reduce) {
    .hgal__hint { animation: none; }
    @supports (animation-timeline: scroll()) {
      .hgal { height: auto; }
      .hgal__sticky { position: static; height: auto; overflow: visible; }
      .hgal__track {
        width: auto;
        overflow-x: auto;
        scroll-snap-type: x mandatory;
        animation: none;
      }
    }
  }
</style>