Domphy

card3D

A Cards block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call card3D() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full implementation: continuous 1 pointermove-driven rotateX/rotateY on the card body (transition disabled during tracking for zero-lag feel), eased reset transition on pointer-leave, and a generic per-item depth API (x/y/z/rotateX/rotateY/rotateZ) so callers can configure their own 'popped' layers; defaults to a 4-item demo (heading, paragraph, image, footer link) with sensible depth presets per the spec's own guidance. No gaps.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "3D Card" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// content card that tilts in 3D perspective toward the cursor and lifts
// its inner elements into separated floating layers on hover.
//
// Two independent transforms compose the effect: the outer wrapper sets a
// CSS `perspective`, and the inner card-body's `rotateX`/`rotateY` is
// written straight to `element.style.transform` on every `pointermove` —
// no easing while hovering, so tracking reads as immediate/1:1, matching
// this package's `magicCard` mouse-tracking idiom (direct style writes,
// not reactive `State`, for a continuous high-frequency pointer signal).
// A `transition` is toggled on only for the `pointerleave` reset, so the
// snap-back to flat is a smooth eased tween while live tracking stays
// snappy. Each "popped" content item then just carries its own *static*
// `translate3d(...)`/`rotate*` — no per-item animation loop — because
// depth separation reads visually the moment the shared parent is tilted.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { heading, link, paragraph, small } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";
import type { ThemeColor } from "@domphy/theme";

export interface Card3DItemDepth {
  /** Horizontal translate, in px. Defaults to `0`. */
  x?: number;
  /** Vertical translate, in px. Defaults to `0`. */
  y?: number;
  /** Translate along the Z axis (the "pop" amount), in px. Defaults to `0`. */
  z?: number;
  /** Rotation around the X axis, in deg. Defaults to `0`. */
  rotateX?: number;
  /** Rotation around the Y axis, in deg. Defaults to `0`. */
  rotateY?: number;
  /** Rotation around the Z axis, in deg. Defaults to `0`. */
  rotateZ?: number;
}

export interface Card3DItemSpec {
  /** The content wrapped by this depth layer. */
  content: DomphyElement;
  /** Depth/rotation this item's layer carries. Defaults to a flat, non-popped layer. */
  depth?: Card3DItemDepth;
}

export interface Card3DProps {
  /** Content layers, each with its own depth/rotation. Defaults to a 4-item demo (heading, paragraph, image, footer). */
  items?: Card3DItemSpec[];
  /** Card surface color family. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Maximum pointer-driven tilt, in deg, at the card's edge. Defaults to `14`. */
  maxRotateDegrees?: number;
  /** CSS `perspective()` distance, in px. Defaults to `900`. */
  perspectiveDistance?: number;
  /** Passthrough style merged onto the outer perspective wrapper. */
  style?: StyleObject;
  /** Passthrough style merged onto the inner card-body surface. */
  bodyStyle?: StyleObject;
}

// Generic abstract placeholder graphic — an inline SVG data URI, no network
// fetch and no real photo (same idiom `pixelImage.ts`/`asciiArt.ts` use for
// their own default demo imagery elsewhere in this package).
const PLACEHOLDER_IMAGE_MARKUP =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 170">' +
  '<rect width="300" height="170" fill="#111827"/>' +
  '<circle cx="215" cy="55" r="40" fill="#38bdf8"/>' +
  '<path d="M0 140 L80 80 L140 120 L200 70 L300 130 L300 170 L0 170 Z" fill="#1f2937"/>' +
  "</svg>";
const PLACEHOLDER_IMAGE_URI = `data:image/svg+xml,${encodeURIComponent(PLACEHOLDER_IMAGE_MARKUP)}`;

function defaultItems(): Card3DItemSpec[] {
  return [
    {
      content: { h3: "Aceternity Cards", $: [heading()] } as DomphyElement,
      depth: { z: 24 },
    },
    {
      content: {
        p: "Hover to feel the depth — this composition floats above the base card as you move your cursor.",
        $: [paragraph({ color: "neutral" })],
      } as DomphyElement,
      depth: { z: 18 },
    },
    {
      content: {
        img: null,
        src: PLACEHOLDER_IMAGE_URI,
        alt: "",
        ariaHidden: "true",
        style: {
          width: "100%",
          display: "block",
          objectFit: "cover",
          borderRadius: themeSpacing(3),
        } as StyleObject,
      } as DomphyElement<"img">,
      depth: { z: 60 },
    },
    {
      content: {
        div: [
          { a: "Try now →", href: "#", $: [link()] } as DomphyElement,
          { small: "v2.0", $: [small({ color: "neutral" })] } as DomphyElement,
        ],
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        } as StyleObject,
      } as DomphyElement<"div">,
      depth: { z: 32 },
    },
  ];
}

function itemTransform(depth: Card3DItemDepth): string {
  const x = depth.x ?? 0;
  const y = depth.y ?? 0;
  const z = depth.z ?? 0;
  const rotateX = depth.rotateX ?? 0;
  const rotateY = depth.rotateY ?? 0;
  const rotateZ = depth.rotateZ ?? 0;
  return `translate3d(${x}px, ${y}px, ${z}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg)`;
}

/**
 * A content card that tilts in 3D perspective toward the cursor and lifts
 * its inner elements into separated floating layers on hover. Call with no
 * arguments for a working demo card.
 */
function card3D(props: Card3DProps = {}): DomphyElement<"div"> {
  const items = props.items && props.items.length > 0 ? props.items : defaultItems();
  const color = props.color ?? "neutral";
  const maxRotateDegrees = props.maxRotateDegrees ?? 14;
  const perspectiveDistance = props.perspectiveDistance ?? 900;

  const itemElements: DomphyElement<"div">[] = items.map((item, index) => ({
    div: [item.content],
    _key: `card3d-item-${index}`,
    style: {
      transform: itemTransform(item.depth ?? {}),
      transformStyle: "preserve-3d",
      willChange: "transform",
    } as StyleObject,
  }));

  let cardBodyElement: HTMLElement | null = null;

  const cardBody: DomphyElement<"div"> = {
    div: itemElements,
    dataTone: "shift-15",
    _onMount: (node: ElementNode) => {
      cardBodyElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      cardBodyElement = null;
    },
    style: {
      position: "relative",
      display: "flex",
      flexDirection: "column",
      gap: themeSpacing(4),
      padding: themeSpacing(6),
      borderRadius: themeSpacing(4),
      transformStyle: "preserve-3d",
      transform: "rotateX(0deg) rotateY(0deg)",
      transition: "transform 300ms ease-out",
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      outline: (listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      ...(props.bodyStyle ?? {}),
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [cardBody],
    style: {
      perspective: `${perspectiveDistance}px`,
      width: "fit-content",
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const wrapperElement = node.domElement as HTMLElement | null;
      if (!wrapperElement) return;

      const handlePointerMove = (event: PointerEvent) => {
        if (!cardBodyElement) return;
        const rect = wrapperElement.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) return;
        const offsetX = (event.clientX - rect.left) / rect.width - 0.5;
        const offsetY = (event.clientY - rect.top) / rect.height - 0.5;
        const rotateY = offsetX * maxRotateDegrees * 2;
        const rotateX = -offsetY * maxRotateDegrees * 2;
        cardBodyElement.style.transition = "none";
        cardBodyElement.style.transform = `rotateX(${rotateX.toFixed(2)}deg) rotateY(${rotateY.toFixed(2)}deg)`;
      };
      const handlePointerLeave = () => {
        if (!cardBodyElement) return;
        cardBodyElement.style.transition = "transform 300ms ease-out";
        cardBodyElement.style.transform = "rotateX(0deg) rotateY(0deg)";
      };

      wrapperElement.addEventListener("pointermove", handlePointerMove);
      wrapperElement.addEventListener("pointerleave", handlePointerLeave);

      node.addHook("Remove", () => {
        wrapperElement.removeEventListener("pointermove", handlePointerMove);
        wrapperElement.removeEventListener("pointerleave", handlePointerLeave);
      });
    },
  } as DomphyElement<"div">;
}

export { card3D };

← Back to Aceternity UI catalog