Direction Aware Hover
Подложка въезжает в карточку с той же кромки, через которую зашёл курсор, и уезжает в ту, через которую он вышел; карточка наклоняется туда же. Сторона считается по углу от центра, с поправкой на пропорции коробки.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | DirectionAwareItem[] | DEFAULT_ITEMS | |
| tilt? | number | 12 | Наклон карточки, градусы. |
DirectionAwareItem — что внутри
- name: string
- note?: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/direction-aware.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 = `/* Наведение, знающее сторону входа. */
.dra {
width: 100%;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
perspective: 900px; /* наклон карточки должен быть настоящим 3D, иначе он не читается */
}
.dra-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--ds-4, 18px);
max-width: 640px;
margin: 0 auto;
padding: 0;
list-style: none;
}
/* Клиппер оверлея — сама карточка: плашка уезжает за её край и там обязана исчезать. */
.dra-card {
position: relative;
overflow: hidden;
border-radius: var(--ds-radius, 14px);
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
background: var(--ds-surface, oklch(0.17 0.02 270));
transform-style: preserve-3d;
}
.dra-inner {
position: relative;
z-index: 1;
display: grid;
gap: 6px;
padding: 20px 18px 22px;
min-height: 128px;
align-content: end;
will-change: transform;
}
.dra-name {
font-size: var(--ds-text-md, 1rem);
font-weight: 640;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.dra-note {
font-size: var(--ds-text-sm, 0.82rem);
color: var(--ds-muted, oklch(0.7 0.02 265));
}
/* Плашка стоит ПОД содержимым (z-index 0 против 1): она подложка, а не занавес. */
.dra-overlay {
position: absolute;
inset: 0;
z-index: 0;
visibility: hidden;
opacity: 0;
background: linear-gradient(
145deg,
color-mix(in oklch, var(--ds-accent, oklch(0.72 0.16 300)) 82%, transparent),
color-mix(in oklch, var(--ds-accent-2, oklch(0.7 0.14 220)) 68%, transparent)
);
}
`;
export interface DirectionAwareItem {
name: string;
note?: string;
}
export interface DirectionAwareProps {
items?: DirectionAwareItem[];
/** Наклон карточки, градусы. */
tilt?: number;
}
const DEFAULT_ITEMS: DirectionAwareItem[] = [
{ name: "Карточка 01", note: "заведи курсор сверху" },
{ name: "Карточка 02", note: "заведи курсор слева" },
{ name: "Карточка 03", note: "заведи курсор снизу" },
{ name: "Карточка 04", note: "заведи курсор справа" },
];
/**
* Единичный вектор СТОРОНЫ, с которой курсор вошёл в элемент.
*
* Классический приём (Noel Delgado): берём угол от центра к точке входа и округляем его
* до одной из четырёх четвертей. Координаты перед этим нормируются по пропорциям коробки —
* без этого у широкой карточки почти любой вход давал бы «слева/справа», а верх и низ
* были бы недостижимы: угол считается в пикселях, а не в долях стороны.
*/
const SIDE_VEC: Array<[number, number]> = [[0, -1], [1, 0], [0, 1], [-1, 0]];
const enterVec = (el: Element, ev: PointerEvent): [number, number] => {
const r = el.getBoundingClientRect();
const w = r.width;
const h = r.height;
const x = (ev.clientX - r.left - w / 2) * (w > h ? h / w : 1);
const y = (ev.clientY - r.top - h / 2) * (h > w ? w / h : 1);
return SIDE_VEC[Math.round(Math.atan2(y, x) / 1.5707963 + 5) % 4]!;
};
/**
* Наведение, знающее сторону входа курсора.
*
* Плашка-подложка въезжает С ТОЙ ЖЕ кромки, через которую зашёл курсор, и уезжает
* в ту, через которую он вышел. Карточка наклоняется туда же. Направление и на входе,
* и на выходе считается ЗАНОВО — если посчитать один раз на входе, выход в другую
* сторону выглядит как рывок назад.
*
* Плашка ставится за кадр через `xPercent`/`yPercent`, а не через `left`/`top`: проценты
* трансформа берутся от размера САМОГО элемента, поэтому одно и то же правило работает
* при любой ширине карточки и не требует замера.
*/
export function DirectionAware({ items = DEFAULT_ITEMS, tilt = 12 }: DirectionAwareProps) {
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;
const off: Array<() => void> = [];
const ctx = gsap.context(() => {
root.querySelectorAll<HTMLElement>("[data-da]").forEach((card) => {
const inner = card.querySelector<HTMLElement>(".dra-inner");
const overlay = card.querySelector<HTMLElement>(".dra-overlay");
if (!inner || !overlay) return;
const enter = (ev: PointerEvent) => {
const [ox, oy] = enterVec(card, ev);
gsap.killTweensOf([overlay, inner]);
gsap.set(overlay, { xPercent: ox * 100, yPercent: oy * 100, autoAlpha: 1 });
gsap.to(overlay, { xPercent: 0, yPercent: 0, duration: 0.5, ease: "power3.out" });
gsap.to(inner, {
rotateX: oy * -tilt, rotateY: ox * tilt,
x: ox * 10, y: oy * 10, scale: 1.05,
duration: 0.5, ease: "power3.out",
});
};
const leave = (ev: PointerEvent) => {
const [ox, oy] = enterVec(card, ev);
gsap.killTweensOf([overlay, inner]);
gsap.to(overlay, {
xPercent: ox * 100, yPercent: oy * 100,
duration: 0.45, ease: "power3.in",
onComplete: () => gsap.set(overlay, { autoAlpha: 0 }),
});
gsap.to(inner, {
rotateX: 0, rotateY: 0, x: 0, y: 0, scale: 1,
duration: 0.5, ease: "power3.out",
});
};
card.addEventListener("pointerenter", enter);
card.addEventListener("pointerleave", leave);
// gsap.context() откатывает твины, но не слушатели — снимаем их сами
off.push(() => {
card.removeEventListener("pointerenter", enter);
card.removeEventListener("pointerleave", leave);
});
});
}, root);
return () => {
off.forEach((fn) => fn());
ctx.revert();
};
}, [tilt, items]);
return (
<div className="dra" ref={rootRef}>
<style href="motiva-direction-aware" precedence="medium">{css}</style>
<ul className="dra-grid">
{items.map((it) => (
<li className="dra-card" key={it.name} data-da>
<span className="dra-overlay" aria-hidden="true" />
<div className="dra-inner">
<span className="dra-name">{it.name}</span>
{it.note ? <span className="dra-note">{it.note}</span> : null}
</div>
</li>
))}
</ul>
</div>
);
}