*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #070a12;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--neon-purple: #ae52ff;
}
html {
background: var(--bg);
color: var(--text);
font-family: 'Inter', 'SF Pro Display', system-ui, sans-serif;
}
body { overflow-x: hidden; }
/* ── Intro ── */
.intro {
min-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.eyebrow {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 1rem;
}
h1 {
font-size: clamp(2.2rem, 6vw, 4.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
background: linear-gradient(135deg, #fff, var(--accent), var(--neon-purple));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: clamp(0.9rem, 2vw, 1.1rem);
color: var(--muted);
max-width: 460px;
margin-top: 0.75rem;
line-height: 1.6;
}
.scroll-hint {
margin-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.scroll-arrow {
width: 1px;
height: 28px;
background: linear-gradient(to bottom, var(--accent), transparent);
animation: arrow-pulse 1.8s ease-in-out infinite;
}
@keyframes arrow-pulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 1; transform: scaleY(1); }
}
.reduced-motion .scroll-arrow { animation: none; opacity: 0.5; }
/* ── Progress bar ── */
.progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.05);
z-index: 100;
opacity: 0;
transition: opacity 0.3s;
}
.progress-bar.visible { opacity: 1; }
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent), var(--neon-purple));
transform-origin: left;
will-change: width;
}
/* ── Horizontal section ── */
.horizontal-wrap {
position: relative;
overflow: hidden;
}
.horizontal-track {
display: flex;
width: max-content;
will-change: transform;
}
/* ── Panels ── */
.panel {
width: 80vw;
max-width: 700px;
height: 100vh;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.panel-inner {
background: linear-gradient(
135deg,
hsl(var(--hue, 200) 50% 10%),
hsl(var(--hue, 200) 60% 16%)
);
border: 1px solid hsl(var(--hue, 200) 40% 25%);
border-radius: 20px;
padding: 3rem;
width: 100%;
max-width: 550px;
min-height: 380px;
display: flex;
flex-direction: column;
justify-content: center;
will-change: transform, opacity;
transition: box-shadow 0.4s ease;
}
.panel-inner:hover {
box-shadow: 0 8px 48px hsla(var(--hue, 200), 60%, 30%, 0.15);
}
.panel-number {
font-size: 4rem;
font-weight: 700;
color: hsla(var(--hue, 200), 60%, 60%, 0.15);
line-height: 1;
margin-bottom: 1rem;
}
.panel-inner h2 {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
color: hsl(var(--hue, 200) 70% 80%);
margin-bottom: 1rem;
}
.panel-inner p {
font-size: 1rem;
color: var(--muted);
line-height: 1.6;
max-width: 380px;
}
/* ── Outro ── */
.outro {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.outro h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
}
.btn-back {
display: inline-block;
padding: 0.7rem 2rem;
border-radius: 999px;
border: 1px solid rgba(134, 232, 255, 0.3);
color: var(--accent);
text-decoration: none;
font: 600 0.85rem/1 'Inter', system-ui, sans-serif;
transition: all 0.25s ease;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.08);
border-color: var(--accent);
}
/* ── Reduced motion ── */
.reduced-motion .panel-inner {
opacity: 1 !important;
transform: none !important;
}
@media (max-width: 640px) {
.panel { width: 90vw; padding: 1rem; }
.panel-inner { padding: 2rem; min-height: 300px; }
}
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger);
// ── Demo shell ──
initDemoShell({
title: 'Horizontal Scroll Gallery',
category: 'scroll',
tech: ['gsap', 'lenis', 'scrolltrigger'],
});
// ── Lenis ──
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add('reduced-motion');
window.addEventListener('motion-preference', (e) => {
document.documentElement.classList.toggle('reduced-motion', e.detail.reduced);
ScrollTrigger.refresh();
});
// ── Refs ──
const track = document.getElementById('horizontal-track');
const panels = gsap.utils.toArray('.panel');
const progressFill = document.getElementById('progress-fill');
const progressBar = document.querySelector('.progress-bar');
// ── Calculate total scroll distance ──
function getScrollWidth() {
return track.scrollWidth - window.innerWidth;
}
// ── Horizontal scroll pin ──
const horizontalTween = gsap.to(track, {
x: () => -getScrollWidth(),
ease: 'none',
scrollTrigger: {
trigger: '.horizontal-wrap',
pin: true,
scrub: 1,
end: () => `+=${getScrollWidth()}`,
invalidateOnRefresh: true,
onUpdate: (self) => {
// Update progress bar
progressFill.style.width = `${self.progress * 100}%`;
},
onEnter: () => progressBar.classList.add('visible'),
onLeave: () => progressBar.classList.remove('visible'),
onEnterBack: () => progressBar.classList.add('visible'),
onLeaveBack: () => progressBar.classList.remove('visible'),
},
});
// ── Panel entrance animations ──
if (!reduced) {
panels.forEach((panel) => {
const inner = panel.querySelector('.panel-inner');
gsap.set(inner, { opacity: 0.3, scale: 0.88 });
gsap.to(inner, {
opacity: 1,
scale: 1,
duration: 1,
ease: 'power2.out',
scrollTrigger: {
trigger: panel,
containerAnimation: horizontalTween,
start: 'left 80%',
end: 'left 30%',
scrub: 1,
},
});
});
}
// ── Intro animations ──
if (!reduced) {
const introTl = gsap.timeline({ defaults: { ease: 'expo.out' } });
gsap.set('.intro .eyebrow', { opacity: 0, y: 20 });
gsap.set('.intro h1', { opacity: 0, y: 40 });
gsap.set('.intro .subtitle', { opacity: 0, y: 25 });
gsap.set('.intro .scroll-hint', { opacity: 0 });
introTl
.to('.intro .eyebrow', { opacity: 1, y: 0, duration: 0.7, delay: 0.3 })
.to('.intro h1', { opacity: 1, y: 0, duration: 0.9 }, '-=0.4')
.to('.intro .subtitle', { opacity: 1, y: 0, duration: 0.7 }, '-=0.5')
.to('.intro .scroll-hint', { opacity: 1, duration: 0.5 }, '-=0.3');
}
// ── Outro reveal ──
if (!reduced) {
gsap.set('.outro h2', { opacity: 0, y: 40 });
gsap.set('.btn-back', { opacity: 0, y: 20 });
gsap.to('.outro h2', {
opacity: 1, y: 0, duration: 0.8, ease: 'expo.out',
scrollTrigger: { trigger: '.outro', start: 'top 70%' },
});
gsap.to('.btn-back', {
opacity: 1, y: 0, duration: 0.6, ease: 'expo.out',
scrollTrigger: { trigger: '.outro', start: 'top 60%' },
});
}
// ── Handle resize ──
window.addEventListener('resize', () => {
ScrollTrigger.refresh();
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Horizontal Scroll Gallery — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<!-- Intro -->
<section class="intro">
<span class="eyebrow">Demo 02</span>
<h1>Horizontal Scroll Gallery</h1>
<p class="subtitle">Scroll down — the gallery scrolls sideways. Each panel reveals with scale and opacity transitions.</p>
<div class="scroll-hint">
<span>Scroll to explore</span>
<div class="scroll-arrow"></div>
</div>
</section>
<!-- Horizontal section -->
<section class="horizontal-wrap">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="horizontal-track" id="horizontal-track">
<div class="panel" style="--hue: 200;">
<div class="panel-inner">
<span class="panel-number">01</span>
<h2>Kinetic<br>Typography</h2>
<p>Letters and words choreographed with scroll-driven timelines.</p>
</div>
</div>
<div class="panel" style="--hue: 270;">
<div class="panel-inner">
<span class="panel-number">02</span>
<h2>Particle<br>Systems</h2>
<p>Thousands of elements forming organic, flowing structures.</p>
</div>
</div>
<div class="panel" style="--hue: 330;">
<div class="panel-inner">
<span class="panel-number">03</span>
<h2>Scroll<br>Choreography</h2>
<p>Precisely timed sequences that unfold with the user's scroll.</p>
</div>
</div>
<div class="panel" style="--hue: 45;">
<div class="panel-inner">
<span class="panel-number">04</span>
<h2>Shader<br>Art</h2>
<p>GPU-powered visuals creating mesmerizing real-time patterns.</p>
</div>
</div>
<div class="panel" style="--hue: 160;">
<div class="panel-inner">
<span class="panel-number">05</span>
<h2>3D<br>Environments</h2>
<p>Immersive spaces built with WebGL and Three.js.</p>
</div>
</div>
<div class="panel" style="--hue: 15;">
<div class="panel-inner">
<span class="panel-number">06</span>
<h2>Magnetic<br>Interactions</h2>
<p>Elements that attract and repel based on cursor proximity.</p>
</div>
</div>
</div>
</section>
<!-- Outro -->
<section class="outro">
<h2>Gallery complete.</h2>
<a href="/" class="btn-back">Back to Showcase</a>
</section>
<script type="module" src="script.js"></script>
</body>
</html>