Draggable Gallery
Перетаскиваемая галерея: тяни ленту мышью или тачем, отпусти — доедет по инерции (Draggable + InertiaPlugin).
Установка
npx shadcn@latest add https://motiva.pages.dev/r/draggable-gallery.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
const SLIDES = [280, 230, 170, 60, 330, 20, 110].map((hue, i) => ({
n: String(i + 1).padStart(2, "0"),
art: `linear-gradient(150deg, oklch(0.55 0.16 ${hue} / 0.8) 0%, oklch(0.2 0.04 ${hue}) 70%), oklch(0.17 0.02 270)`,
}));
/**
* Перетаскиваемая галерея: тяни ленту мышью/тачем, отпусти — доезжает по инерции
* (GSAP Draggable + InertiaPlugin, bounds + edgeResistance). Reduced-motion — без инерции.
*/
export function DraggableGallery() {
const viewport = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
useEffect(() => {
const vp = viewport.current;
const tr = track.current;
if (!vp || !tr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
gsap.registerPlugin(Draggable, InertiaPlugin);
const [drag] = Draggable.create(tr, {
type: "x",
bounds: vp,
inertia: !frozen,
edgeResistance: 0.72,
cursor: "grab",
activeCursor: "grabbing",
});
return () => {
drag?.kill();
};
}, []);
return (
<div style={{ width: "100vw", overflowX: "clip", color: "oklch(0.92 0.01 265)" }}>
<div style={{ textAlign: "center", marginBottom: 14, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
⇠ тяни ленту ⇢
</div>
<div ref={viewport} style={{ overflow: "visible", paddingInline: "6vw" }}>
<div
ref={track}
style={{
display: "flex",
gap: 18,
width: "max-content",
touchAction: "pan-y",
}}
>
{SLIDES.map((s) => (
<figure
key={s.n}
style={{
margin: 0,
flexShrink: 0,
width: 190,
height: 250,
borderRadius: 16,
border: "1px solid oklch(0.3 0.02 270)",
background: s.art,
display: "flex",
alignItems: "flex-end",
padding: "0.9rem",
userSelect: "none",
}}
>
<figcaption style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.75 }}>
{s.n}
</figcaption>
</figure>
))}
</div>
</div>
</div>
);
}
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
/**
* Draggable Gallery — vanilla JS/GSAP (без React). Тяни ленту, отпусти — доедет по инерции.
*
* <div data-dg-viewport style="overflow:visible">
* <div data-dg-track style="display:flex;gap:18px;width:max-content;touch-action:pan-y">…карточки…</div>
* </div>
* <script type="module">
* import { initDraggableGallery } from './draggable-gallery.vanilla.js';
* const destroy = initDraggableGallery(document.querySelector('[data-dg-viewport]'));
* </script>
*
* @param {HTMLElement} viewport — контейнер-границы с [data-dg-track] внутри
* @returns {() => void} destroy
*/
export function initDraggableGallery(viewport) {
const track = viewport.querySelector("[data-dg-track]");
if (!track) return () => {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
gsap.registerPlugin(Draggable, InertiaPlugin);
const [drag] = Draggable.create(track, {
type: "x",
bounds: viewport,
inertia: !frozen, // reduced-motion: без инерционного доезда
edgeResistance: 0.72,
cursor: "grab",
activeCursor: "grabbing",
});
return () => drag?.kill();
}