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

Push Spring

Товар выбрасывается из карточки пружиной с перелётом и лёгкой тряской, а направление берётся из того, с какой стороны в карточку вошёл курсор: товар отскакивает от указателя, включая диагонали.

gsaphovermouse-movespringintermediate~23 КБreduced-motion ✓
↗

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
items?PushSpringItem[]DEFAULT_ITEMS
mode?"up" | "away""away"`up` — выброс всегда вверх. `away` — на сторону, противоположную входу курсора, включая диагонали.
scale?number1.14Во сколько раз растёт товар.
dist?number26Смещение выброса, px.
rot?number4Амплитуда тряски, градусы.
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

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0