Motiva
Мои коллекции
← каталог

Horizontal Scroll

Секция горизонтального скролла: вертикальный скролл двигает пришпиленную ленту панелей вбок (ScrollTrigger pin+scrub, Lenis).

gsaplenisscrollintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Установка

npx shadcn@latest add https://motiva.pages.dev/r/horizontal-scroll.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

const PANELS = [
  { n: "01", title: "Sticky", tint: "oklch(0.62 0.19 280 / 0.4)" },
  { n: "02", title: "Scrub", tint: "oklch(0.66 0.16 230 / 0.4)" },
  { n: "03", title: "Lenis", tint: "oklch(0.68 0.15 170 / 0.35)" },
  { n: "04", title: "Release", tint: "oklch(0.7 0.16 60 / 0.35)" },
];

/**
 * Горизонтальный скролл-секция: вертикальный скролл (Lenis) двигает ленту панелей вбок.
 * «Пин» сделан через position:sticky (устойчивее ScrollTrigger-pin внутри iframe/нестандартных
 * страниц): обёртке задаётся высота 100vh + ширина ленты, секция липнет, ScrollTrigger только
 * скрабит x по прогрессу обёртки. Уважает reduced-motion / data-shot (статичная лента).
 */
export function HorizontalScroll() {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;

    gsap.registerPlugin(ScrollTrigger);
    let lenis: Lenis | null = null;
    let rafFn: ((time: number) => void) | null = null;
    let setH: (() => void) | null = null;

    const ctx = gsap.context(() => {
      if (frozen) return;

      lenis = new Lenis({ duration: 1.1 });
      lenis.on("scroll", ScrollTrigger.update);
      rafFn = (time: number) => lenis?.raf(time * 1000);
      gsap.ticker.add(rafFn);
      gsap.ticker.lagSmoothing(0);

      const wrap = el.querySelector<HTMLElement>(".hs-wrap");
      const track = el.querySelector<HTMLElement>(".hs-track");
      if (!wrap || !track) return;

      const dist = () => Math.max(0, track.scrollWidth - window.innerWidth);
      setH = () => {
        wrap.style.height = `${window.innerHeight + dist()}px`;
      };
      setH();
      ScrollTrigger.addEventListener("refreshInit", setH);

      gsap.to(track, {
        x: () => -dist(),
        ease: "none",
        scrollTrigger: {
          trigger: wrap,
          start: "top top",
          end: "bottom bottom",
          scrub: true,
          invalidateOnRefresh: true,
        },
      });
    }, el);

    return () => {
      ctx.revert();
      if (setH) ScrollTrigger.removeEventListener("refreshInit", setH);
      if (rafFn) gsap.ticker.remove(rafFn);
      lenis?.destroy();
    };
  }, []);

  return (
    // width:100vw + clip: лента не должна раздувать интринсик-ширину предка (грид-сцена превью)
    <div ref={root} style={{ width: "100vw", overflowX: "clip", color: "oklch(0.92 0.01 265)" }}>
      <section style={{ height: "55vh", display: "grid", placeItems: "center" }}>
        <div style={{ textAlign: "center" }}>
          <div style={{ fontSize: "1.9rem", fontWeight: 700, letterSpacing: "-0.02em" }}>Horizontal Scroll</div>
          <div style={{ marginTop: 8, fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.6 }}>
            крути вниз ↓ — лента поедет вбок
          </div>
        </div>
      </section>

      <div className="hs-wrap" style={{ height: "160vh" }}>
        <section
          style={{
            position: "sticky",
            top: 0,
            height: "100vh",
            overflow: "hidden",
            display: "flex",
            alignItems: "center",
          }}
        >
          <div
            className="hs-track"
            style={{ display: "flex", gap: "4vw", paddingInline: "6vw", willChange: "transform" }}
          >
            {PANELS.map((p) => (
              <article
                key={p.n}
                style={{
                  flexShrink: 0,
                  width: "min(420px, 64vw)",
                  height: "56vh",
                  borderRadius: 20,
                  border: "1px solid oklch(0.3 0.02 270)",
                  background: `linear-gradient(160deg, ${p.tint}, oklch(0.17 0.02 270)) oklch(0.17 0.02 270)`,
                  display: "flex",
                  flexDirection: "column",
                  justifyContent: "space-between",
                  padding: "1.6rem",
                }}
              >
                <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.55 }}>
                  {p.n}
                </span>
                <span style={{ fontSize: "1.6rem", fontWeight: 650, letterSpacing: "-0.02em" }}>{p.title}</span>
              </article>
            ))}
          </div>
        </section>
      </div>

      <section style={{ height: "45vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
          конец — дальше обычный скролл
        </span>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); паттерн — GSAP demosv1.0.0