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

Tilt Card

Карточка наклоняется в 3D вслед за курсором, по поверхности скользит блик.

gsapmouse-movehoverparallaxintermediate~23 КБreduced-motion ✓

Пропсы

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

ПропТипПо умолчаниюЧто это
childrenReactNode
max?number12

Установка

npx shadcn@latest add https://motiva.pages.dev/r/tilt-card.json

зависимости: gsap@^3.15.0

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useRef, type PointerEvent, type ReactNode } from "react";
import gsap from "gsap";

/** Карточка с 3D-наклоном к курсору и бликом. Уважает reduced-motion / data-shot. */
export function TiltCard({ children, max = 12 }: { children: ReactNode; max?: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const glare = useRef<HTMLDivElement>(null);

  const frozen = () =>
    document.documentElement.hasAttribute("data-shot") ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const onMove = (e: PointerEvent<HTMLDivElement>) => {
    const el = ref.current;
    if (!el || frozen()) return;
    const r = el.getBoundingClientRect();
    const px = (e.clientX - r.left) / r.width;
    const py = (e.clientY - r.top) / r.height;
    gsap.to(el, {
      rotateY: (px - 0.5) * max * 2,
      rotateX: -(py - 0.5) * max * 2,
      transformPerspective: 800,
      duration: 0.4,
      ease: "power2.out",
    });
    if (glare.current) gsap.to(glare.current, { "--gx": `${px * 100}%`, "--gy": `${py * 100}%`, opacity: 1, duration: 0.3 });
  };

  const onLeave = () => {
    const el = ref.current;
    if (!el) return;
    gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
    if (glare.current) gsap.to(glare.current, { opacity: 0, duration: 0.4 });
  };

  return (
    <div
      ref={ref}
      onPointerMove={onMove}
      onPointerLeave={onLeave}
      style={{
        position: "relative",
        transformStyle: "preserve-3d",
        borderRadius: 18,
        border: "1px solid oklch(0.3 0.02 270)",
        background: "oklch(0.18 0.02 270)",
        padding: "2rem",
        color: "oklch(0.92 0.01 265)",
        width: 260,
      }}
    >
      {children}
      <div
        ref={glare}
        style={{
          position: "absolute",
          inset: 0,
          borderRadius: "inherit",
          pointerEvents: "none",
          opacity: 0,
          background:
            "radial-gradient(circle at var(--gx, 50%) var(--gy, 50%), rgba(255,255,255,0.35), transparent 55%)",
        }}
      />
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0