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

Product Lift

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

csshoverbasicreduced-motion ✓
↗

Пропсы

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

ПропТипПо умолчаниюЧто это
items?ProductLiftItem[]DEFAULT_ITEMS
ProductLiftItem — что внутри
  • name: string
  • flavour?: string— Подпись под названием: вкус, объём, состав.
  • price: string— Цена строкой — валюту и разделители задаёшь сам.
  • img: string— Товар на ПРОЗРАЧНОМ фоне: приём читается по силуэту, на плашке он пропадает.
  • imgW?: number— Свои размеры файла — иначе браузеру нечем зарезервировать место и кадр прыгает.
  • imgH?: number
  • href?: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/product-lift.json
Что нужно в проекте до установки
  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

Код

/* Карточка товара с выталкиванием картинки.

   Приём: по наведению товар растёт от СВОЕГО НИЗА, приподнимается и чуть кренится,
   выезжая за верхнюю кромку карточки. Карточка и соседи при этом стоят — двигается
   только картинка, поэтому сетка не дёргается.

   Клипа тут нет НИГДЕ по стволу: ни `overflow: hidden`, ни `clip-path`. Любой клиппер
   на карточке или на списке срежет ровно то, ради чего эффект и делается. Место под
   выезд даёт `padding-top` у корня — не запас у картинки. */

.plf {
  --plf-lift: 1.6rem; /* подъём */
  --plf-grow: 1.15; /* масштаб */
  --plf-tilt: -5deg; /* крен */
  --plf-dur: 0.4s;

  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));
}

.plf-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;
}

.plf-card {
  position: relative;
  z-index: 1; /* поднятый товар должен лечь ПОВЕРХ соседей */
  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));
  transition:
    border-color var(--plf-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
    background-color var(--plf-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}

.plf-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`, а не `::after`: оба псевдоэлемента и картинка позиционированы,
   а позиционированные красятся в порядке DOM — `::after` лёг бы ПОВЕРХ бутылки
   и читался серым мазком поперёк товара (поймано на светлой теме).
   На тёмной теме тень не видна, и это правильно: чёрное на чёрном не бывает видно. */
.plf-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);
  transition:
    transform var(--plf-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
    opacity var(--plf-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Центрируем через left/right/margin, а НЕ через translateX: transform занят приёмом,
   и композиция двух правил превратилась бы в перезапись одного другим.
   Высота 118% и вынос низа на -4% — поправка на прозрачные поля самого файла:
   товар в кадре занимает ~81% высоты, и без этого он висел бы в воздухе. */
.plf-img {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -4%;
  margin-inline: auto;
  height: 118%;
  width: auto;
  transform-origin: 50% 100%; /* растём от донышка, а не от центра */
  transition: transform var(--plf-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}

.plf-meta {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  gap: 2px var(--ds-2, 8px);
}

.plf-name {
  font-size: var(--ds-text-md, 0.98rem);
  font-weight: 600;
  letter-spacing: var(--ds-tracking-tight, -0.02em);
}

.plf-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;
}

.plf-flavour {
  grid-column: 1 / -1;
  font-size: var(--ds-text-sm, 0.82rem);
  color: var(--ds-muted, oklch(0.65 0.02 265));
}

/* Приём включается только там, где есть настоящее наведение мышью.
   На тач-экране :hover залипает после тапа — товар остался бы поднятым. */
@media (hover: hover) and (pointer: fine) {
  .plf-card:hover {
    z-index: 2;
    border-color: var(--ds-accent, oklch(0.72 0.16 300));
    background: var(--ds-surface-2, oklch(0.2 0.025 270));
  }

  .plf-card:hover .plf-img,
  .plf-card:focus-within .plf-img {
    transform: scale(var(--plf-grow)) translateY(calc(var(--plf-lift) * -1)) rotate(var(--plf-tilt));
  }

  .plf-card:hover .plf-cell::before {
    transform: scaleX(0.78);
    opacity: 0.5;
  }
}

/* Клавиатура: тот же приём по фокусу — без него эффект видит только мышь. */
.plf-card:focus-within {
  z-index: 2;
  border-color: var(--ds-accent, oklch(0.72 0.16 300));
}

.plf-card a:focus-visible {
  outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300));
  outline-offset: 3px;
  border-radius: 4px;
}

.plf-link {
  color: inherit;
  text-decoration: none;
}

.plf-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}

@media (prefers-reduced-motion: reduce) {
  .plf-img,
  .plf-cell::before,
  .plf-card {
    transition: none;
  }

  .plf-card:hover .plf-img,
  .plf-card:focus-within .plf-img {
    transform: none;
  }
}

html[data-shot] .plf-img,
html[data-shot] .plf-cell::before,
html[data-shot] .plf-card {
  transition: none;
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0