NEMAWASHI LAB

H-Scroll

GSAP

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

HorizontalScroll.tsx181 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe