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.
---
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>