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

Direction Aware Hover

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

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

Пропсы

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

ПропТипПо умолчаниюЧто это
items?DirectionAwareItem[]DEFAULT_ITEMS
tilt?number12Наклон карточки, градусы.
DirectionAwareItem — что внутри
  • name: string
  • note?: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/direction-aware.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 = `/* Наведение, знающее сторону входа. */

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