Image Trail
Шлейф изображений за курсором: каждые N пикселей пути всплывает картинка со случайным наклоном и тут же тает (пул тайлов).
Установка
npx shadcn@latest add https://motiva.pages.dev/r/image-trail.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 { useRef, type PointerEvent } from "react";
import gsap from "gsap";
const ARTS = [280, 230, 170, 60, 330, 20].map(
(hue) => `linear-gradient(150deg, oklch(0.6 0.16 ${hue} / 0.9) 0%, oklch(0.2 0.04 ${hue}) 75%)`,
);
const SPAWN_DIST = 90;
const POOL = 10;
/**
* Шлейф изображений за курсором: каждые N пикселей пути в точке курсора всплывает «картинка»
* (пул переиспользуемых тайлов), тут же уменьшаясь и исчезая. Уважает reduced-motion / data-shot.
*/
export function ImageTrail() {
const root = useRef<HTMLDivElement>(null);
const last = useRef({ x: -1e4, y: -1e4 });
const idx = useRef(0);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const onMove = (e: PointerEvent<HTMLDivElement>) => {
const el = root.current;
if (!el || frozen()) return;
const r = el.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
if (Math.hypot(x - last.current.x, y - last.current.y) < SPAWN_DIST) return;
last.current = { x, y };
const tiles = el.querySelectorAll<HTMLElement>(".it-tile");
const tile = tiles[idx.current % POOL];
if (!tile) return;
idx.current += 1;
tile.style.background = ARTS[idx.current % ARTS.length]!;
tile.style.zIndex = String(idx.current);
gsap
.timeline()
.fromTo(
tile,
{ x, y, xPercent: -50, yPercent: -50, scale: 0.3, opacity: 0, rotation: gsap.utils.random(-14, 14) },
{ scale: 1, opacity: 1, duration: 0.3, ease: "power2.out" },
)
.to(tile, { scale: 0.2, opacity: 0, duration: 0.6, ease: "power2.in" }, "+=0.12");
};
return (
<div ref={root} onPointerMove={onMove} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: "crosshair" }}>
{Array.from({ length: POOL }, (_, i) => (
<div
key={i}
className="it-tile"
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
width: 130,
height: 170,
borderRadius: 12,
border: "1px solid oklch(0.34 0.02 270)",
opacity: 0,
pointerEvents: "none",
willChange: "transform, opacity",
}}
/>
))}
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
pointerEvents: "none",
color: "oklch(0.92 0.01 265)",
}}
>
<div style={{ textAlign: "center", opacity: 0.6 }}>
<div style={{ fontSize: "1.5rem", fontWeight: 700 }}>Image Trail</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem" }}>
веди курсором — за ним шлейф картинок
</div>
</div>
</div>
</div>
);
}
import gsap from "gsap";
/**
* Image Trail — vanilla JS/GSAP (без React). Шлейф изображений за курсором:
* каждые N пикселей пути всплывает картинка и тут же тает (пул переиспользуемых тайлов).
*
* <section id="zone" style="position:relative;overflow:hidden">…</section>
* <script type="module">
* import { initImageTrail } from './image-trail.vanilla.js';
* const destroy = initImageTrail(document.querySelector('#zone'), {
* arts: ["url('1.jpg') center/cover", "url('2.jpg') center/cover"],
* });
* </script>
*
* @param {HTMLElement} root — зона (position:relative, overflow:hidden)
* @param {{ arts?: string[], spawnDist?: number, pool?: number, width?: number, height?: number }} [options]
* @returns {() => void} destroy
*/
export function initImageTrail(root, options = {}) {
const {
arts = [280, 230, 170, 60, 330, 20].map(
(hue) => `linear-gradient(150deg, oklch(0.6 0.16 ${hue} / 0.9) 0%, oklch(0.2 0.04 ${hue}) 75%)`,
),
spawnDist = 90,
pool = 10,
width = 130,
height = 170,
} = options;
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const tiles = Array.from({ length: pool }, () => {
const t = document.createElement("div");
t.style.cssText = `position:absolute;left:0;top:0;width:${width}px;height:${height}px;border-radius:12px;
border:1px solid oklch(0.34 0.02 270);opacity:0;pointer-events:none;will-change:transform,opacity;`;
root.appendChild(t);
return t;
});
let last = { x: -1e4, y: -1e4 };
let idx = 0;
const onMove = (e) => {
if (frozen()) return;
const r = root.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
if (Math.hypot(x - last.x, y - last.y) < spawnDist) return;
last = { x, y };
const tile = tiles[idx % pool];
idx += 1;
tile.style.background = arts[idx % arts.length];
tile.style.zIndex = String(idx);
gsap
.timeline()
.fromTo(
tile,
{ x, y, xPercent: -50, yPercent: -50, scale: 0.3, opacity: 0, rotation: gsap.utils.random(-14, 14) },
{ scale: 1, opacity: 1, duration: 0.3, ease: "power2.out" },
)
.to(tile, { scale: 0.2, opacity: 0, duration: 0.6, ease: "power2.in" }, "+=0.12");
};
root.addEventListener("pointermove", onMove);
return () => {
root.removeEventListener("pointermove", onMove);
tiles.forEach((t) => t.remove());
};
}