Horizontal full-viewport panel slider driven by GSAP Observer, which captures wheel, touch, and pointer input and maps vertical scroll intent to horizontal navigation. Each transition is a single power3.inOut tween of xPercent, with the incoming title popping in via back.out(2) scale-and-fade. A tolerance of 30px debounces accidental swipes. Dot indicators at the bottom double as click targets, and a thin progress bar fills proportionally.
ONE
Observer + Horizontal
TWO
Wheel & Touch
THREE
No Page Scroll
FOUR
Direct Input
FIVE
Smooth Lerp
SIX
GSAP Power
Source
"use client";
import { useRef, useEffect, useState } from "react";
import gsap from "gsap";
import { Observer } from "gsap/Observer";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(Observer);
const PANELS = [
{ bg: "#1a1a2e", color: "#fff", title: "ONE", subtitle: "Observer + Horizontal" },
{ bg: "#e74c3c", color: "#fff", title: "TWO", subtitle: "Wheel & Touch" },
{ bg: "#f5f0e8", color: "#1a1a2e", title: "THREE", subtitle: "No Page Scroll" },
{ bg: "#2d2b6b", color: "#fff", title: "FOUR", subtitle: "Direct Input" },
{ bg: "#0d0d0d", color: "#fff", title: "FIVE", subtitle: "Smooth Lerp" },
{ bg: "#d4edda", color: "#1a1a2e", title: "SIX", subtitle: "GSAP Power" },
];
export default function HorizontalScroll() {
const trackRef = useRef<HTMLDivElement>(null);
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!trackRef.current) return;
const track = trackRef.current;
const panelCount = PANELS.length;
let index = 0;
let animating = false;
function goTo(i: number) {
if (animating || i < 0 || i >= panelCount) return;
animating = true;
index = i;
setCurrent(i);
gsap.to(track, {
xPercent: -i * (100 / panelCount),
duration: 0.8,
ease: "power3.inOut",
onComplete: () => { animating = false; },
});
const titles = track.querySelectorAll(".hs-title");
gsap.fromTo(
titles[i],
{ scale: 0.6, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.6, ease: "back.out(2)", delay: 0.15 }
);
}
const obs = Observer.create({
target: track.parentElement!,
type: "wheel,touch,pointer",
onDown: () => goTo(index - 1),
onUp: () => goTo(index + 1),
onLeft: () => goTo(index + 1),
onRight: () => goTo(index - 1),
tolerance: 30,
preventDefault: true,
});
return () => obs.kill();
}, []);
return (
<div
className="fixed inset-0 overflow-hidden select-none"
style={{ background: "#0a0a0a" }}
>
<div
ref={trackRef}
style={{
display: "flex",
width: `${PANELS.length * 100}vw`,
height: "100%",
}}
>
{PANELS.map((panel, i) => (
<div
key={i}
className="hs-panel"
style={{
width: "100vw",
height: "100%",
background: panel.bg,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<p
className="hs-title"
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: "'wght' 600, 'SRIF' 500",
fontSize: "clamp(48px, 10vw, 120px)",
color: panel.color,
lineHeight: 1,
}}
>
{panel.title}
</p>
<p
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 13,
color: panel.color,
opacity: 0.5,
marginTop: 16,
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{panel.subtitle}
</p>
</div>
))}
</div>
{/* Dots indicator */}
<div
className="fixed bottom-10 left-1/2 -translate-x-1/2 flex gap-3"
style={{ zIndex: 20 }}
>
{PANELS.map((_, i) => (
<div
key={i}
style={{
width: i === current ? 24 : 8,
height: 8,
borderRadius: 4,
background: i === current ? "#fff" : "rgba(255,255,255,0.25)",
transition: "all 0.4s ease",
cursor: "pointer",
}}
onClick={() => {
if (!trackRef.current) return;
setCurrent(i);
gsap.to(trackRef.current, {
xPercent: -i * (100 / PANELS.length),
duration: 0.8,
ease: "power3.inOut",
});
}}
/>
))}
</div>
{/* Progress bar */}
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
height: 3,
zIndex: 20,
background: "rgba(255,255,255,0.1)",
}}
>
<div
style={{
height: "100%",
width: `${((current + 1) / PANELS.length) * 100}%`,
background: "#fff",
transition: "width 0.8s ease",
}}
/>
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink from="H-SCROLL" to="← LAB" href="/lab" className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors" style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }} />
</div>
</div>
);
}