Web InnoventixFreeCode

Horizontal Scroll Gallery

Original · free

Vertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

byWeb InnoventixReact + Tailwind
scrollhorizontalgallery
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scroll-horizontal-gallery.json
scroll-horizontal-gallery.tsx
"use client";

import { useRef, useState } from "react";
import type { ReactNode } from "react";
import { motion, useScroll, useTransform, useSpring, useMotionValueEvent } from "motion/react";

type GalleryCard = {
  index: string;
  kicker: string;
  title: string;
  blurb: string;
  meta: string;
  gradient: string;
  darkGradient: string;
  accent: string;
  art: ReactNode;
};

function ArtOrbit(): ReactNode {
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      <defs>
        <radialGradient id="szh-orbit-g" cx="50%" cy="40%" r="70%">
          <stop offset="0%" stopColor="rgba(255,255,255,0.9)" />
          <stop offset="100%" stopColor="rgba(255,255,255,0)" />
        </radialGradient>
      </defs>
      <circle cx="100" cy="90" r="70" fill="none" stroke="rgba(255,255,255,0.35)" strokeWidth="1" />
      <circle cx="100" cy="90" r="48" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1" />
      <circle cx="100" cy="90" r="26" fill="url(#szh-orbit-g)" />
      <circle cx="100" cy="20" r="5" fill="#fff" />
      <circle cx="170" cy="90" r="4" fill="rgba(255,255,255,0.8)" />
      <circle cx="52" cy="90" r="3.5" fill="rgba(255,255,255,0.7)" />
    </svg>
  );
}

function ArtBars(): ReactNode {
  const bars = [30, 62, 44, 88, 54, 72, 40, 96, 60];
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      {bars.map((h, i) => (
        <rect
          key={i}
          x={16 + i * 20}
          y={170 - h}
          width="10"
          height={h}
          rx="5"
          fill="rgba(255,255,255,0.85)"
          opacity={0.4 + (i % 3) * 0.2}
        />
      ))}
    </svg>
  );
}

function ArtMesh(): ReactNode {
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      {Array.from({ length: 7 }).map((_, r) => (
        <path
          key={r}
          d={`M0 ${20 + r * 26} Q 50 ${4 + r * 26} 100 ${20 + r * 26} T 200 ${20 + r * 26}`}
          fill="none"
          stroke="rgba(255,255,255,0.5)"
          strokeWidth="1"
        />
      ))}
    </svg>
  );
}

function ArtGrid(): ReactNode {
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      {Array.from({ length: 6 }).map((_, y) =>
        Array.from({ length: 6 }).map((_, x) => (
          <circle
            key={`${x}-${y}`}
            cx={24 + x * 30}
            cy={24 + y * 30}
            r={2 + ((x + y) % 3)}
            fill="rgba(255,255,255,0.8)"
            opacity={0.35 + ((x * y) % 5) * 0.12}
          />
        ))
      )}
    </svg>
  );
}

function ArtWave(): ReactNode {
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      <path
        d="M0 120 C 40 60 80 180 120 110 S 180 60 200 120 L 200 200 L 0 200 Z"
        fill="rgba(255,255,255,0.28)"
      />
      <path
        d="M0 140 C 50 90 90 190 140 130 S 190 100 200 150"
        fill="none"
        stroke="rgba(255,255,255,0.8)"
        strokeWidth="2"
      />
    </svg>
  );
}

function ArtType(): ReactNode {
  return (
    <svg viewBox="0 0 200 200" className="h-full w-full" aria-hidden="true">
      <text
        x="100"
        y="118"
        textAnchor="middle"
        fontSize="120"
        fontWeight="800"
        fill="rgba(255,255,255,0.9)"
        fontFamily="ui-serif, Georgia, serif"
      >
        Aa
      </text>
      <line x1="30" y1="150" x2="170" y2="150" stroke="rgba(255,255,255,0.5)" strokeWidth="1" />
    </svg>
  );
}

const CARDS: GalleryCard[] = [
  {
    index: "01",
    kicker: "Brand System",
    title: "Helios Identity",
    blurb: "A living identity for a solar-energy startup, built around orbiting light and warm gradients.",
    meta: "Branding · 2026",
    gradient: "from-amber-400 via-orange-500 to-rose-500",
    darkGradient: "dark:from-amber-500 dark:via-orange-600 dark:to-rose-600",
    accent: "text-amber-200",
    art: <ArtOrbit />,
  },
  {
    index: "02",
    kicker: "Data Product",
    title: "Cadence Analytics",
    blurb: "Real-time revenue dashboards that make quarterly numbers feel like a heartbeat, not a spreadsheet.",
    meta: "Product · 2026",
    gradient: "from-sky-400 via-indigo-500 to-violet-600",
    darkGradient: "dark:from-sky-500 dark:via-indigo-600 dark:to-violet-700",
    accent: "text-sky-200",
    art: <ArtBars />,
  },
  {
    index: "03",
    kicker: "Motion",
    title: "Meridian Mesh",
    blurb: "Generative gradient meshes rendered on the GPU for a fintech landing page that never sits still.",
    meta: "WebGL · 2026",
    gradient: "from-emerald-400 via-teal-500 to-cyan-600",
    darkGradient: "dark:from-emerald-500 dark:via-teal-600 dark:to-cyan-700",
    accent: "text-emerald-200",
    art: <ArtMesh />,
  },
  {
    index: "04",
    kicker: "Interface Kit",
    title: "Atlas Grid",
    blurb: "A 240-component design system with pixel-tuned spacing, shipped as tokens across three platforms.",
    meta: "Design System · 2026",
    gradient: "from-fuchsia-400 via-purple-500 to-indigo-600",
    darkGradient: "dark:from-fuchsia-500 dark:via-purple-600 dark:to-indigo-700",
    accent: "text-fuchsia-200",
    art: <ArtGrid />,
  },
  {
    index: "05",
    kicker: "Campaign",
    title: "Tidewave",
    blurb: "An interactive scroll story for an ocean-conservation nonprofit, animated on a single canvas layer.",
    meta: "Editorial · 2026",
    gradient: "from-cyan-400 via-blue-500 to-indigo-600",
    darkGradient: "dark:from-cyan-500 dark:via-blue-600 dark:to-indigo-700",
    accent: "text-cyan-200",
    art: <ArtWave />,
  },
  {
    index: "06",
    kicker: "Typography",
    title: "Serif Revival",
    blurb: "A variable display serif with 14 optical sizes, drawn for a magazine's next hundred covers.",
    meta: "Type · 2026",
    gradient: "from-rose-400 via-pink-500 to-fuchsia-600",
    darkGradient: "dark:from-rose-500 dark:via-pink-600 dark:to-fuchsia-700",
    accent: "text-rose-200",
    art: <ArtType />,
  },
];

export default function ScrollHorizontalGallery() {
  const trackRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState<number>(0);

  const { scrollYProgress } = useScroll({
    target: trackRef,
    offset: ["start start", "end end"],
  });

  const smooth = useSpring(scrollYProgress, {
    stiffness: 90,
    damping: 24,
    restDelta: 0.001,
  });

  const x = useTransform(smooth, [0, 1], ["0%", "-66%"]);
  const progressWidth = useTransform(smooth, [0, 1], ["0%", "100%"]);
  const railRotate = useTransform(smooth, [0, 1], [0, -6]);

  useMotionValueEvent(smooth, "change", (v: number) => {
    const idx = Math.min(CARDS.length - 1, Math.max(0, Math.round(v * (CARDS.length - 1))));
    setActive(idx);
  });

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
      <style>{`
        @keyframes szh-drift {
          0%, 100% { transform: translate3d(0,0,0) scale(1); }
          50% { transform: translate3d(0,-18px,0) scale(1.06); }
        }
        @keyframes szh-shimmer {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .szh-drift { animation: szh-drift 11s ease-in-out infinite; }
        .szh-shimmer {
          background-size: 200% auto;
          animation: szh-shimmer 6s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .szh-drift, .szh-shimmer { animation: none !important; }
        }
      `}</style>

      {/* atmospheric background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
        <div className="szh-drift absolute -left-32 top-1/4 h-96 w-96 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="szh-drift absolute -right-24 top-1/3 h-[28rem] w-[28rem] rounded-full bg-fuchsia-300/30 blur-3xl dark:bg-fuchsia-700/20" />
        <div
          className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
          style={{
            backgroundImage:
              "linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
            backgroundSize: "56px 56px",
          }}
        />
      </div>

      {/* tall wrapper drives the horizontal pan */}
      <div ref={trackRef} className="relative h-[320vh]">
        {/* sticky stage */}
        <div className="sticky top-0 flex h-screen flex-col overflow-hidden">
          {/* header */}
          <header className="mx-auto w-full max-w-7xl px-6 pt-12 sm:px-10 lg:pt-16">
            <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
              <div className="max-w-xl">
                <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
                  <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
                  Selected Work
                </span>
                <h2 className="mt-5 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl lg:text-6xl">
                  A studio in{" "}
                  <span className="szh-shimmer bg-gradient-to-r from-indigo-500 via-fuchsia-500 to-amber-500 bg-clip-text text-transparent">
                    motion
                  </span>
                </h2>
                <p className="mt-4 max-w-md text-base text-slate-600 dark:text-slate-400">
                  Scroll to pan the rail. Six projects, one continuous horizon of brand, product and type.
                </p>
              </div>

              <div className="flex items-center gap-4 sm:flex-col sm:items-end">
                <div className="text-right">
                  <div className="font-mono text-5xl font-bold tabular-nums text-slate-900 dark:text-white">
                    {CARDS[active].index}
                  </div>
                  <div className="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400">
                    / 06
                  </div>
                </div>
              </div>
            </div>
          </header>

          {/* rail */}
          <div className="flex flex-1 items-center">
            <motion.div
              style={{ x, rotate: railRotate }}
              className="flex origin-left items-stretch gap-6 pl-6 pr-[10vw] sm:gap-8 sm:pl-10"
            >
              {CARDS.map((card, i) => (
                <article
                  key={card.index}
                  className="group relative flex h-[58vh] w-[78vw] shrink-0 flex-col justify-between overflow-hidden rounded-3xl border border-white/40 p-7 shadow-2xl shadow-slate-900/10 ring-1 ring-black/5 sm:w-[62vw] md:w-[46vw] lg:w-[38vw] dark:border-white/10 dark:shadow-black/40"
                >
                  {/* card gradient surface */}
                  <div
                    aria-hidden="true"
                    className={`absolute inset-0 -z-10 bg-gradient-to-br ${card.gradient} ${card.darkGradient}`}
                  />
                  <div
                    aria-hidden="true"
                    className="absolute inset-0 -z-10 opacity-30 mix-blend-overlay"
                    style={{
                      backgroundImage:
                        "radial-gradient(circle at 30% 20%, rgba(255,255,255,0.8), transparent 45%)",
                    }}
                  />

                  {/* top row */}
                  <div className="flex items-start justify-between">
                    <span className={`text-xs font-semibold uppercase tracking-[0.2em] ${card.accent}`}>
                      {card.kicker}
                    </span>
                    <span className="font-mono text-sm font-bold text-white/80">{card.index}</span>
                  </div>

                  {/* art */}
                  <div className="pointer-events-none absolute inset-x-0 top-1/2 mx-auto h-40 w-40 -translate-y-1/2 opacity-90 sm:h-48 sm:w-48">
                    {card.art}
                  </div>

                  {/* bottom text */}
                  <div className="relative">
                    <h3 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
                      {card.title}
                    </h3>
                    <p className="mt-2 max-w-xs text-sm leading-relaxed text-white/85">
                      {card.blurb}
                    </p>
                    <div className="mt-5 flex items-center justify-between">
                      <span className="text-xs font-medium uppercase tracking-widest text-white/70">
                        {card.meta}
                      </span>
                      <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/20 text-white ring-1 ring-white/40 backdrop-blur transition-transform duration-300 group-hover:translate-x-1">
                        <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                          <path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                      </span>
                    </div>
                  </div>
                </article>
              ))}
            </motion.div>
          </div>

          {/* footer progress */}
          <div className="mx-auto w-full max-w-7xl px-6 pb-10 sm:px-10">
            <div className="flex items-center gap-4">
              <span className="font-mono text-xs text-slate-500 dark:text-slate-400">01</span>
              <div className="relative h-1 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                <motion.div
                  style={{ width: progressWidth }}
                  className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-indigo-500 via-fuchsia-500 to-amber-500"
                />
              </div>
              <span className="font-mono text-xs text-slate-500 dark:text-slate-400">06</span>
              <span className="hidden items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-slate-500 sm:inline-flex dark:text-slate-400">
                Scroll
                <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <path d="M12 5v14M6 13l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →