Push Spring
Товар выбрасывается из карточки пружиной с перелётом и лёгкой тряской, а направление берётся из того, с какой стороны в карточку вошёл курсор: товар отскакивает от указателя, включая диагонали.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | PushSpringItem[] | DEFAULT_ITEMS | |
| mode? | "up" | "away" | "away" | `up` — выброс всегда вверх. `away` — на сторону, противоположную входу курсора, включая диагонали. |
| scale? | number | 1.14 | Во сколько раз растёт товар. |
| dist? | number | 26 | Смещение выброса, px. |
| rot? | number | 4 | Амплитуда тряски, градусы. |
PushSpringItem — что внутри
- name: string
- note?: string
- price?: string
- img: string
- imgW?: number— Свои размеры файла — иначе браузеру нечем зарезервировать место и кадр прыгает.
- imgH?: number
Установка
npx shadcn@latest add https://motiva.pages.dev/r/push-spring.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";
const css = `/* Выброс товара пружиной. Клипа по стволу нет: любой \`overflow: hidden\` на карточке
или на списке срежет ровно то, ради чего приём и делается. Место под выброс даёт
\`padding\` корня, а не запас внутри картинки. */
.pspr {
width: 100%;
padding-top: 6rem;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
}
.pspr-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--ds-4, 18px);
max-width: 780px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.pspr-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--ds-3, 12px);
padding: 14px;
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
border-radius: var(--ds-radius, 14px);
background: var(--ds-surface, oklch(0.17 0.02 270));
}
.pspr-cell {
position: relative;
aspect-ratio: 3 / 4; /* выше ячейка — крупнее товар при той же ширине */
border-radius: calc(var(--ds-radius, 14px) - 4px);
background:
radial-gradient(120% 80% at 50% 100%, var(--ds-surface-2, oklch(0.22 0.03 270)), transparent 70%),
var(--ds-bg, oklch(0.14 0.02 275));
}
/* Тень — в \`::before\`: позиционированные красятся в порядке DOM, и \`::after\`
лёг бы ПОВЕРХ товара серым мазком. */
.pspr-cell::before {
content: "";
position: absolute;
inset: auto 15% 1.5% 15%;
height: 12px;
border-radius: 50%;
background: radial-gradient(closest-side, oklch(0 0 0 / 0.5), transparent);
}
/* Центрируем через left/right/margin, а НЕ через translateX: \`transform\` целиком
отдан GSAP, и своё правило он бы просто перезаписал. Высота 118% и вынос низа
на −4% — поправка на прозрачные поля файла (товар занимает ~81% кадра). */
.pspr-img {
position: absolute;
left: 0;
right: 0;
bottom: -4%;
margin-inline: auto;
height: 118%;
width: auto;
transform-origin: 50% 100%; /* растём от донышка, как в источнике */
will-change: transform;
}
.pspr-meta {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 2px var(--ds-2, 8px);
}
.pspr-name {
font-size: var(--ds-text-md, 0.98rem);
font-weight: 600;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.pspr-price {
font-size: var(--ds-text-md, 0.98rem);
font-weight: 600;
color: var(--ds-accent, oklch(0.72 0.16 300));
font-variant-numeric: tabular-nums;
}
.pspr-note {
grid-column: 1 / -1;
font-size: var(--ds-text-sm, 0.82rem);
color: var(--ds-muted, oklch(0.65 0.02 265));
}
`;
export interface PushSpringItem {
name: string;
note?: string;
price?: string;
img: string;
/** Свои размеры файла — иначе браузеру нечем зарезервировать место и кадр прыгает. */
imgW?: number;
imgH?: number;
}
export interface PushSpringProps {
items?: PushSpringItem[];
/**
* `up` — выброс всегда вверх.
* `away` — на сторону, противоположную входу курсора, включая диагонали.
*/
mode?: "up" | "away";
/** Во сколько раз растёт товар. */
scale?: number;
/** Смещение выброса, px. */
dist?: number;
/** Амплитуда тряски, градусы. */
rot?: number;
}
const DEFAULT_ITEMS: PushSpringItem[] = [
{ name: "Позиция 01", note: "короткая подпись", price: "$12", img: "https://motiva.pages.dev/img/bottle-amber-465x1524.webp" },
{ name: "Позиция 02", note: "короткая подпись", price: "$14", img: "https://motiva.pages.dev/img/bottle-cherry-465x1524.webp" },
{ name: "Позиция 03", note: "короткая подпись", price: "$16", img: "https://motiva.pages.dev/img/bottle-cola-734x2274.webp", imgW: 734, imgH: 2274 },
];
/**
* Выброс товара пружиной с перелётом и лёгкой тряской.
*
* Чем отличается от `product-lift`: там чистый CSS-переход, ровный и одинаковый.
* Здесь пружина с ПЕРЕЛЁТОМ (`elastic.out`) плюс затухающая тряска поворота, и в режиме
* `away` направление выброса берётся из того, С КАКОЙ СТОРОНЫ курсор вошёл в карточку —
* товар отскакивает от курсора. Направление НЕПРЕРЫВНОЕ: вход снизу — вверх, слева —
* вправо, из нижне-левого угла — в верхне-правый.
*
* Вектор считается от ЦЕНТРА к точке входа и нормируется: делить на что-то одно нельзя,
* иначе у широкой карточки диагональ вырождается в горизонталь.
*
* Тряска — не отдельный твин поворота поверх основного, а `keyframes` внутри одного:
* два независимых твина одного и того же свойства GSAP разрешает последним запущенным,
* и колебание просто съело бы выброс.
*/
export function PushSpring({
items = DEFAULT_ITEMS,
mode = "away",
scale = 1.14,
dist = 26,
rot = 4,
}: PushSpringProps) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
// Приём живёт только там, где есть настоящее наведение мышью: на тач-экране
// «стороны входа» не существует, а состояние после тапа залипает.
if (
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
window.matchMedia("(pointer: coarse)").matches
) return;
// gsap.context() откатывает ТВИНЫ, но не слушатели — их снимаем сами,
// иначе при перемонтировании компонента они копятся.
const off: Array<() => void> = [];
const ctx = gsap.context(() => {
root.querySelectorAll<HTMLElement>("[data-push]").forEach((card) => {
const img = card.querySelector<HTMLElement>(".pspr-img");
if (!img) return;
const enter = (ev: PointerEvent) => {
let px = 0;
let py = -dist;
if (mode === "away") {
const r = card.getBoundingClientRect();
const dx = (ev.clientX - (r.left + r.width / 2)) / (r.width / 2);
const dy = (ev.clientY - (r.top + r.height / 2)) / (r.height / 2);
const len = Math.hypot(dx, dy) || 1;
px = -(dx / len) * dist;
py = -(dy / len) * dist;
}
card.style.zIndex = "5";
gsap.killTweensOf(img);
gsap.to(img, { scale, x: px, y: py, duration: 0.9, ease: "elastic.out(1, 0.4)" });
const sign = Math.sign(px) || Math.sign(py) || 1; // тряска в сторону выброса
gsap.fromTo(
img,
{ rotation: 0 },
{
rotation: 0,
keyframes: { rotation: [0, sign * rot, sign * -rot * 0.65, sign * rot * 0.35, 0] },
duration: 0.6,
ease: "power2.out",
},
);
};
const leave = () => {
gsap.killTweensOf(img);
gsap.to(img, {
scale: 1, x: 0, y: 0, rotation: 0,
duration: 0.7, ease: "elastic.out(1, 0.55)",
onComplete: () => { card.style.zIndex = ""; },
});
};
card.addEventListener("pointerenter", enter);
card.addEventListener("pointerleave", leave);
off.push(() => {
card.removeEventListener("pointerenter", enter);
card.removeEventListener("pointerleave", leave);
card.style.zIndex = "";
});
});
}, root);
return () => {
off.forEach((fn) => fn());
ctx.revert();
};
}, [mode, scale, dist, rot, items]);
return (
<div className="pspr" ref={rootRef}>
<style href="motiva-push-spring" precedence="medium">{css}</style>
<ul className="pspr-row">
{items.map((it) => (
<li className="pspr-card" key={it.name} data-push>
<div className="pspr-cell">
<img
className="pspr-img"
src={it.img}
alt={it.name}
width={it.imgW ?? 465}
height={it.imgH ?? 1524}
loading="lazy"
decoding="async"
draggable={false}
/>
</div>
<div className="pspr-meta">
<span className="pspr-name">{it.name}</span>
{it.price ? <span className="pspr-price">{it.price}</span> : null}
{it.note ? <span className="pspr-note">{it.note}</span> : null}
</div>
</li>
))}
</ul>
</div>
);
}