bottega

experimental

3D Tilt Card

A card that tracks the cursor in real time, tilting on X/Y with a spring 'magnet lag', a radial glare spot that slides across the surface, and an edge-lit border shimmer that brightens toward the cursor vector. Children opt into translateZ parallax via data-depth. Keyboard users get a static, focus-visible-ringed surface; reduced-motion users get a CSS-only border/accent hover affordance with no JS motion. asChild renders the card as a real button/anchor.

3D Tilt Card

Move your cursor across the surface — spring lag, a sliding glare, and edge-lit shimmer.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/card-3d-tilt

Usage

Usagetsx
<Card3dTilt />

Props

PropTypeDefaultDescription
childrenReact.ReactNodeCard contents. Descendants with `data-depth` (0–1) parallax on translateZ.
tiltMaxnumber12Maximum rotation in degrees on each axis.
glarebooleantrueEnable/disable the radial glare highlight layer.
glareOpacitynumber0.18Peak opacity of the glare spot at cursor center (0–1).
scalenumber1.03Scale factor applied on hover entry.
perspectivenumber800CSS perspective depth in px — lower = more dramatic tilt.
spring{ stiffness: number; damping: number; }{ stiffness: 280, damping: 28 }Motion spring config for rotation and glare follow.
asChildbooleanfalseRender the single child element as the root (lightweight Slot — no extra dependency). Use to make the card a `<button>` or `<a>`; the host element then carries its own native role.

Source

card-3d-tilt.tsxtsx
"use client";

import {
  Children,
  cloneElement,
  isValidElement,
  useEffect,
  useRef,
  useState,
  type ReactElement,
  type Ref,
} from "react";
import {
  motion,
  useMotionValue,
  useSpring,
  useReducedMotion,
  useMotionValueEvent,
  type HTMLMotionProps,
} from "motion/react";
import styles from "./card-3d-tilt.module.css";

export type Card3DTiltProps = Omit<
  HTMLMotionProps<"div">,
  "style" | "ref" | "children"
> & {
  /** Card contents. Descendants with `data-depth` (0–1) parallax on translateZ. */
  children?: React.ReactNode;
  /** Maximum rotation in degrees on each axis. */
  tiltMax?: number;
  /** Enable/disable the radial glare highlight layer. */
  glare?: boolean;
  /** Peak opacity of the glare spot at cursor center (0–1). */
  glareOpacity?: number;
  /** Scale factor applied on hover entry. */
  scale?: number;
  /** CSS perspective depth in px — lower = more dramatic tilt. */
  perspective?: number;
  /** Motion spring config for rotation and glare follow. */
  spring?: { stiffness: number; damping: number };
  /**
   * Render the single child element as the root (lightweight Slot — no extra
   * dependency). Use to make the card a `<button>` or `<a>`; the host element
   * then carries its own native role.
   */
  asChild?: boolean;
};

const DEFAULT_SPRING = { stiffness: 280, damping: 28 };

/**
 * Smooth resistance near the tilt limit — values past 80% of the range are
 * progressively eased so the card never slams into a hard wall.
 * Input/output normalized to [-1, 1].
 */
function softClamp(n: number) {
  const sign = Math.sign(n);
  const a = Math.min(Math.abs(n), 1);
  if (a <= 0.8) return n;
  const over = (a - 0.8) / 0.2; // 0..1 past the knee
  const eased = 0.8 + 0.2 * (1 - (1 - over) * (1 - over)); // ease-out
  return sign * eased;
}

/** Assign a value to a callback or object ref (no-op for null). */
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref && typeof ref === "object") {
    (ref as React.MutableRefObject<T | null>).current = value;
  }
}

/** Merge two refs into one callback ref so both receive the node. */
function mergeRefs<T>(a: Ref<T> | undefined, b: Ref<T> | undefined) {
  return (node: T | null) => {
    assignRef(a, node);
    assignRef(b, node);
  };
}

/** Compose two event handlers; the user's runs first, then ours. */
function composeHandlers<E>(
  theirs: ((e: E) => void) | undefined,
  ours: (e: E) => void
) {
  return (e: E) => {
    theirs?.(e);
    ours(e);
  };
}

export function Card3DTilt({
  children,
  className = "",
  tiltMax = 12,
  glare = true,
  glareOpacity = 0.18,
  scale = 1.03,
  perspective = 800,
  spring = DEFAULT_SPRING,
  asChild = false,
  role,
  ...props
}: Card3DTiltProps) {
  const ref = useRef<HTMLDivElement>(null);
  const glareRef = useRef<HTMLDivElement>(null);
  const edgeRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLElement>(null);

  // Mount + reduced-motion gate (contract snippet) — server and first client
  // render are byte-identical static; animation only turns on post-hydration.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  // Entrance: single play, triggered once by IntersectionObserver.
  const [entered, setEntered] = useState(false);
  useEffect(() => {
    if (!animate || !ref.current) return;
    const el = ref.current;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          setEntered(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, [animate]);

  // Spring-driven rotation/scale; glare position follows snappily.
  const rotX = useSpring(0, spring);
  const rotY = useSpring(0, spring);
  const sc = useSpring(1, spring);
  const glareX = useMotionValue(50);
  const glareY = useMotionValue(50);
  const glareO = useSpring(0, spring);
  const edgeI = useSpring(0, spring);
  // 0 idle -> 1 hovered; depth children translateZ proportionally to this.
  const lift = useSpring(0, spring);

  const pressed = useRef(false);

  // Apply parallax to opted-in [data-depth] children. Each child floats on
  // translateZ = depth * lift * MAX_LIFT_PX, so content lifts out of the
  // surface without any extra wrapper component. Updated imperatively to keep
  // pointer moves off the render path.
  const MAX_LIFT_PX = 40;
  useMotionValueEvent(lift, "change", (v) => {
    const root = contentRef.current;
    if (!root) return;
    const nodes = root.querySelectorAll<HTMLElement>("[data-depth]");
    nodes.forEach((node) => {
      const depth = Number(node.dataset.depth);
      if (Number.isNaN(depth)) return;
      node.style.transform = `translateZ(${depth * v * MAX_LIFT_PX}px)`;
    });
  });

  // Push glare/edge values to CSS custom props imperatively — no re-render per
  // pointer move; work stays on transform/opacity.
  useMotionValueEvent(glareX, "change", (v) => {
    glareRef.current?.style.setProperty("--glare-x", `${v}%`);
  });
  useMotionValueEvent(glareY, "change", (v) => {
    glareRef.current?.style.setProperty("--glare-y", `${v}%`);
  });
  useMotionValueEvent(glareO, "change", (v) => {
    glareRef.current?.style.setProperty("--glare-opacity", `${v}`);
  });
  useMotionValueEvent(edgeI, "change", (v) => {
    edgeRef.current?.style.setProperty("--edge-intensity", `${v}`);
  });

  function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
    if (!animate || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const px = (e.clientX - rect.left) / rect.width; // 0..1
    const py = (e.clientY - rect.top) / rect.height; // 0..1
    const nx = px * 2 - 1; // -1..1
    const ny = py * 2 - 1;

    const limit = pressed.current ? tiltMax * 0.5 : tiltMax;
    // invert Y so cursor-up tilts the top toward the viewer
    rotX.set(softClamp(-ny) * limit);
    rotY.set(softClamp(nx) * limit);

    if (glare) {
      glareX.set(px * 100);
      glareY.set(py * 100);
      glareO.set(glareOpacity);
    }
    // edge brightens as the cursor nears any edge (further from center = brighter)
    const edgeStrength = Math.min(1, Math.max(Math.abs(nx), Math.abs(ny)));
    edgeI.set(0.35 + edgeStrength * 0.65);
  }

  function handlePointerEnter() {
    if (!animate) return;
    sc.set(pressed.current ? 0.99 : scale);
    lift.set(1);
  }

  function handlePointerLeave() {
    if (!animate) return;
    pressed.current = false;
    // glare fades first (~180ms via CSS) then rotation returns — "slides off".
    glareO.set(0);
    edgeI.set(0);
    rotX.set(0);
    rotY.set(0);
    sc.set(1);
    lift.set(0);
  }

  function handlePointerDown() {
    if (!animate) return;
    pressed.current = true;
    sc.set(0.99);
  }

  function handlePointerUp() {
    if (!animate) return;
    pressed.current = false;
    sc.set(scale);
  }

  // Standalone (non-interactive, non-asChild) cards get role="article".
  // asChild cards let the host element carry its own native role.
  const resolvedRole = role ?? (asChild ? undefined : "article");

  const overlays = (
    <>
      {glare && (
        <div ref={glareRef} className={styles.glare} aria-hidden="true" />
      )}
      <div ref={edgeRef} className={styles.edge} aria-hidden="true" />
    </>
  );

  // asChild: merge our root props/handlers onto the single child element so it
  // becomes the tilt root (e.g. a real <button>/<a>). Lightweight Slot — no dep.
  if (asChild) {
    const child = Children.only(children);
    if (!isValidElement(child)) return null;
    const el = child as ReactElement<{
      className?: string;
      children?: React.ReactNode;
      ref?: Ref<HTMLElement>;
    }> & { ref?: Ref<HTMLElement> };
    // Merge our measuring ref onto the child so IntersectionObserver and
    // getBoundingClientRect see the real root; React 19 exposes ref via props,
    // older via el.ref — cover both.
    const childRef = (el.props as { ref?: Ref<HTMLElement> }).ref ?? el.ref;
    const childProps = el.props as React.HTMLAttributes<HTMLElement>;
    return cloneElement(
      el,
      {
        ...props,
        ref: mergeRefs(childRef, ref as Ref<HTMLElement>),
        className: [styles.root, animate ? styles.animated : "", el.props.className, className]
          .filter(Boolean)
          .join(" "),
        // Compose with any handlers the child (or user via props) already has.
        onPointerMove: composeHandlers(
          childProps.onPointerMove ?? props.onPointerMove,
          handlePointerMove
        ),
        onPointerEnter: composeHandlers(
          childProps.onPointerEnter ?? props.onPointerEnter,
          handlePointerEnter
        ),
        onPointerLeave: composeHandlers(
          childProps.onPointerLeave ?? props.onPointerLeave,
          handlePointerLeave
        ),
        onPointerDown: composeHandlers(
          childProps.onPointerDown ?? props.onPointerDown,
          handlePointerDown
        ),
        onPointerUp: composeHandlers(
          childProps.onPointerUp ?? props.onPointerUp,
          handlePointerUp
        ),
      } as React.HTMLAttributes<HTMLElement> & { ref?: Ref<HTMLElement> },
      <>
        {overlays}
        <span ref={contentRef} className={styles.content}>
          {el.props.children}
        </span>
      </>
    );
  }

  return (
    <motion.div
      {...props}
      ref={ref}
      className={[styles.root, animate ? styles.animated : "", className]
        .filter(Boolean)
        .join(" ")}
      role={resolvedRole}
      onPointerMove={composeHandlers(props.onPointerMove, handlePointerMove)}
      onPointerEnter={composeHandlers(props.onPointerEnter, handlePointerEnter)}
      onPointerLeave={composeHandlers(props.onPointerLeave, handlePointerLeave)}
      onPointerDown={composeHandlers(props.onPointerDown, handlePointerDown)}
      onPointerUp={composeHandlers(props.onPointerUp, handlePointerUp)}
      style={
        animate
          ? {
              perspective,
              transformStyle: "preserve-3d",
              rotateX: rotX,
              rotateY: rotY,
              scale: sc,
            }
          : undefined
      }
      initial={animate ? { opacity: 0, y: 12 } : false}
      animate={
        animate ? (entered ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }) : undefined
      }
      // Entrance is a fade+lift (CSS-token-scale UI transition ~300ms, expressed
      // in seconds for Motion). Spring (above) owns the hover motion, not this.
      transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
    >
      {overlays}
      <div ref={contentRef as React.RefObject<HTMLDivElement>} className={styles.content}>
        {children}
      </div>
    </motion.div>
  );
}

Dependencies

  • motion
  • @bottega/tokens