Tilt Card
Карточка наклоняется в 3D вслед за курсором, по поверхности скользит блик.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| children | ReactNode | — | |
| max? | number | 12 |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/tilt-card.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 { 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>
);
}
import gsap from "gsap";
/**
* Tilt Card — vanilla JS/GSAP (без React). Оживляет существующую карточку:
* 3D-наклон к курсору + блик (слой блика создаётся автоматически).
*
* <div class="card" style="position:relative">…контент…</div>
* <script type="module">
* import { initTiltCard } from './tilt-card.vanilla.js';
* const destroy = initTiltCard(document.querySelector('.card'), { max: 12 });
* </script>
*
* @param {HTMLElement} el — карточка (нужен position:relative и скруглённые углы)
* @param {{ max?: number }} [options] — максимальный угол наклона, град
* @returns {() => void} destroy
*/
export function initTiltCard(el, { max = 12 } = {}) {
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.style.transformStyle = "preserve-3d";
const glare = document.createElement("div");
glare.style.cssText = `position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;
background:radial-gradient(circle at var(--gx,50%) var(--gy,50%), rgba(255,255,255,0.35), transparent 55%);`;
el.appendChild(glare);
const onMove = (e) => {
if (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",
});
gsap.to(glare, { "--gx": `${px * 100}%`, "--gy": `${py * 100}%`, opacity: 1, duration: 0.3 });
};
const onLeave = () => {
gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
gsap.to(glare, { opacity: 0, duration: 0.4 });
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
glare.remove();
gsap.set(el, { rotateX: 0, rotateY: 0 });
};
}