Skip to content

css-3d-card-transforms

<!-- Loaded by combat-effects-upgrade when task involves card tilt, backface-visibility, CSS perspective, or Framer Motion + CSS 3D integration -->

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

<!-- Loaded by combat-effects-upgrade when task involves card tilt, backface-visibility, CSS perspective, or Framer Motion + CSS 3D integration -->

Agent definition

css-3d-card-transforms.md

CSS 3D Card Transforms Reference

<!-- Loaded by combat-effects-upgrade when task involves card tilt, backface-visibility, CSS perspective, or Framer Motion + CSS 3D integration -->

CSS 3D transforms: tilt toward cursor on hover, `backface-visibility: hidden` for flip reveals. GPU-composited only (`transform`, `opacity`). Universal browser support (Chrome 36+, Firefox 16+, Safari 9+).

Tilt Formula

rotateY = (mouseX - cardCenterX) / cardWidth  * MAX_TILT_DEG
rotateX = -(mouseY - cardCenterY) / cardHeight * MAX_TILT_DEG
  • `MAX_TILT_DEG` = 15 (degrees) — beyond 20deg starts looking exaggerated
  • Negate `rotateX` because positive mouseY (mouse below center) should tilt the top toward viewer (negative rotateX in CSS convention)
  • `cardCenter` is measured once from `getBoundingClientRect()` on `mouseenter`, not recalculated on every `mousemove`

---

Complete Component: TiltCard

// src/components/FramedCard.tsx
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react';
import { useRef, useCallback, useState } from 'react';

const MAX_TILT_DEG = 15;
const SPRING_CONFIG = { stiffness: 300, damping: 30 } as const;

interface FramedCardProps {
  card: Card;
  onClick?: () => void;
}

export function FramedCard({ card, onClick }: FramedCardProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const [isTouchDevice] = useState(
    () => window.matchMedia('(hover: none)').matches
  );

  // Raw motion values — these update on every mousemove WITHOUT triggering re-render
  const rawRotateX = useMotionValue(0);
  const rawRotateY = useMotionValue(0);

  // Springs smooth the raw values — tilt follows cursor with a slight lag
  const rotateX = useSpring(rawRotateX, SPRING_CONFIG);
  const rotateY = useSpring(rawRotateY, SPRING_CONFIG);

  // Subtle glare effect: opacity follows rotateY
  const glareOpacity = useTransform(rotateY, [-MAX_TILT_DEG, 0, MAX_TILT_DEG], [0.15, 0, 0.15]);

  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
    if (isTouchDevice || !cardRef.current) return;

    const rect = cardRef.current.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;

    rawRotateY.set((e.clientX - centerX) / (rect.width / 2) * MAX_TILT_DEG);
    rawRotateX.set(-((e.clientY - centerY) / (rect.height / 2)) * MAX_TILT_DEG);
  }, [isTouchDevice, rawRotateX, rawRotateY]);

  const handleMouseLeave = useCallback(() => {
    // Spring back to flat — spring physics handles the animation
    rawRotateX.set(0);
    rawRotateY.set(0);
  }, [rawRotateX, rawRotateY]);

  return (
    // Perspective on the container — children share one vanishing point
    <div className="card-perspective-container" ref={cardRef}>
      <motion.div
        className="framed-card"
        style={{
          rotateX,       // MotionValue — no re-renders, compositor-only updates
          rotateY,
          transformStyle: 'preserve-3d',
        }}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        onClick={onClick}
        // Existing hover scale
        whileHover={isTouchDevice ? undefined : { scale: 1.06 }}
        whileTap={{ scale: 0.97 }}
        transition={{ type: 'spring', stiffness: 400, damping: 28 }}
      >
        {/* Card face */}
        <div className="card-face card-face--front">
          <CardArtwork card={card} />
          <CardStats card={card} />
        </div>

        {/* Glare overlay — follows tilt direction */}
        <motion.div
          className="card-glare"
          style={{ opacity: glareOpacity }}
          aria-hidden="true"
        />
      </motion.div>
    </div>
  );
}

---

Required CSS

/* ─────────────────────────────────────────────
   Card perspective container
   perspective goes on the PARENT — gives children
   a shared vanishing point
   ───────────────────────────────────────────── */
.card-perspective-container {
  perspective: 1000px;
  perspective-origin: center center;
  /* Contain so tilt doesn't affect sibling layout */
  isolation: isolate;
}

/* ─────────────────────────────────────────────
   Card body
   ───────────────────────────────────────────── */
.framed-card {
  position: relative;
  width: 160px;
  height: 240px;
  border-radius: 12px;
  transform-style: preserve-3d;
  cursor: pointer;

  /* GPU layer hint — apply dynamically via JS, or only in hover state */
}

.framed-card:hover {
  will-change: transform;
}

/* Remove will-change when not hovering */
.framed-card:not(:hover) {
  will-change: auto;
}

/* ─────────────────────────────────────────────
   Card faces — used for flip reveal
   ───────────────────────────────────────────── */
.card-face {
  position: absolute;
  inset: 0;
  border-radius: 12px;
  backface-visibility: hidden;          /* Hide when rotated 180deg */
  -webkit-backface-visibility: hidden;  /* Safari */
}

.card-face--front {
  /* Front is naturally visible (0deg rotation) */
}

.card-face--back {
  /* Back starts flipped — visible when card rotates 180deg */
  transform: rotateY(180deg);
  background: url('/card-back.png') center/cover;
}

/* ─────────────────────────────────────────────
   Glare overlay
   ───────────────────────────────────────────── */
.card-glare {
  position: absolute;
  inset: 0;
  border-radius: 12px;
  background: linear-gradient(
    105deg,
    transparent 40%,
    rgba(255, 255, 255, 0.15) 50%,
    transparent 60%
  );
  pointer-events: none;
  /* translateZ pushes it above the card face in 3D space */
  transform: translateZ(1px);
}

---

Card Flip Animation

Used when a face-down card is revealed (e.g. drawing from deck, enemy showing intent).

// src/components/FramedCard.tsx — flip variant
import { motion, AnimatePresence } from 'motion/react';
import { useState } from 'react';

interface FlippableCardProps {
  card: Card;
  isRevealed: boolean;
}

export function FlippableCard({ card, isRevealed
Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked