← каталог
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
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Horizontal Scroll — vanilla JS/GSAP+Lenis (без React). Sticky-«пин»: вертикальный скролл
* везёт ленту панелей вбок. Обёртке высота задаётся автоматически.
*
* <div data-hs-wrap>
* <section data-hs-pin style="position:sticky;top:0;height:100vh;overflow:hidden;display:flex;align-items:center">
* <div data-hs-track style="display:flex;gap:4vw">…панели…</div>
* </section>
* </div>
* <script type="module">
* import { initHorizontalScroll } from './horizontal-scroll.vanilla.js';
* const destroy = initHorizontalScroll(document.querySelector('[data-hs-wrap]'));
* </script>
*
* @param {HTMLElement} wrap — обёртка с [data-hs-track] внутри
* @returns {() => void} destroy
*/
export function initHorizontalScroll(wrap) {
const track = wrap.querySelector("[data-hs-track]");
if (!track) return () => {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) return () => {};
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.1 });
lenis.on("scroll", ScrollTrigger.update);
const rafFn = (t) => lenis.raf(t * 1000);
gsap.ticker.add(rafFn);
gsap.ticker.lagSmoothing(0);
const dist = () => Math.max(0, track.scrollWidth - window.innerWidth);
const setH = () => {
wrap.style.height = `${window.innerHeight + dist()}px`;
};
setH();
ScrollTrigger.addEventListener("refreshInit", setH);
const tween = gsap.to(track, {
x: () => -dist(),
ease: "none",
scrollTrigger: { trigger: wrap, start: "top top", end: "bottom bottom", scrub: true, invalidateOnRefresh: true },
});
return () => {
ScrollTrigger.removeEventListener("refreshInit", setH);
tween.scrollTrigger?.kill();
tween.kill();
gsap.ticker.remove(rafFn);
lenis.destroy();
wrap.style.height = "";
gsap.set(track, { x: 0 });
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); паттерн — GSAP demosv1.0.0