Product Lift
Карточка товара, из которой по наведению выталкивается сам товар: растёт от донышка, приподнимается и чуть кренится, выезжая за кромку карточки. Соседи и сетка стоят на месте — двигается только картинка.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| 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Что нужно в проекте до установки
Файл
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.
Экспорт
Код
/* Карточка товара с выталкиванием картинки.
Приём: по наведению товар растёт от СВОЕГО НИЗА, приподнимается и чуть кренится,
выезжая за верхнюю кромку карточки. Карточка и соседи при этом стоят — двигается
только картинка, поэтому сетка не дёргается.
Клипа тут нет НИГДЕ по стволу: ни `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;
}
const css = `/* Карточка товара с выталкиванием картинки.
Приём: по наведению товар растёт от СВОЕГО НИЗА, приподнимается и чуть кренится,
выезжая за верхнюю кромку карточки. Карточка и соседи при этом стоят — двигается
только картинка, поэтому сетка не дёргается.
Клипа тут нет НИГДЕ по стволу: ни \`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;
}
`;
export interface ProductLiftItem {
name: string;
/** Подпись под названием: вкус, объём, состав. */
flavour?: string;
/** Цена строкой — валюту и разделители задаёшь сам. */
price: string;
/** Товар на ПРОЗРАЧНОМ фоне: приём читается по силуэту, на плашке он пропадает. */
img: string;
/** Свои размеры файла — иначе браузеру нечем зарезервировать место и кадр прыгает. */
imgW?: number;
imgH?: number;
href?: string;
}
export interface ProductLiftProps {
items?: ProductLiftItem[];
}
const DEFAULT_ITEMS: ProductLiftItem[] = [
{ name: "Позиция 01", flavour: "короткая подпись под названием", price: "$12", img: "https://motiva.pages.dev/img/bottle-amber-465x1524.webp" },
{ name: "Позиция 02", flavour: "короткая подпись под названием", price: "$14", img: "https://motiva.pages.dev/img/bottle-cherry-465x1524.webp" },
{ name: "Позиция 03", flavour: "короткая подпись под названием", price: "$16", img: "https://motiva.pages.dev/img/bottle-cola-734x2274.webp", imgW: 734, imgH: 2274 },
];
/**
* Карточка товара, из которой по наведению ВЫТАЛКИВАЕТСЯ картинка.
* Ноль JS: приём живёт на `:hover`/`:focus-within` и одном `transform`.
* Уважает `prefers-reduced-motion` и `data-shot` (см. CSS).
*/
export function ProductLift({ items = DEFAULT_ITEMS }: ProductLiftProps) {
return (
<div className="plf">
{/* href + precedence: React 19 вставит правила ОДИН раз на страницу, сколько бы
экземпляров компонента ни оказалось на странице. */}
<style href="motiva-product-lift" precedence="medium">{css}</style>
<ul className="plf-row">
{items.map((it) => (
<li className="plf-card" key={it.name}>
<div className="plf-cell">
<img
className="plf-img"
src={it.img}
alt={it.name}
width={it.imgW ?? 465}
height={it.imgH ?? 1524}
loading="lazy"
decoding="async"
draggable={false}
/>
</div>
<div className="plf-meta">
<span className="plf-name">
{it.href ? (
<a className="plf-link" href={it.href}>
{it.name}
</a>
) : (
it.name
)}
</span>
<span className="plf-price">{it.price}</span>
{it.flavour ? <span className="plf-flavour">{it.flavour}</span> : null}
</div>
</li>
))}
</ul>
</div>
);
}