Domphy

directionAwareHover

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

Implementation notes

Full behavior match including the diagonal-slicing entry classification (comparing the pointer-enter point against the rectangle's two diagonals, not simple quadrant math, so corner entries resolve to a sensible nearest edge -- see classifyEntryDirection's derivation in the file header comment). Image pans/scales opposite the entry edge and the overlay panel slides in from that same edge on enter, reversing on leave; both driven by direct inline-transform writes with static CSS transitions, plus a snap-without-transition + forced-reflow step so re-entering from a different edge doesn't visibly slide across the old edge first.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Direction Aware Hover" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). An image card that detects which of the 4 edges the cursor
// entered from, then pans the image and slides a text overlay in from that
// same edge.
//
// The key trick (per the spec's research note) is classifying the entry
// point against the rectangle's two DIAGONALS rather than plain quadrant
// math, so corner entries still resolve to a sensible nearest edge. With the
// entry point expressed relative to the card's center as `(x, y)` and the
// two diagonals as the lines `y = (h/w)x` and `y = -(h/w)x`, the sign of
// `d1 = y - (h/w)x` and `d2 = y + (h/w)x` partitions the rectangle into 4
// triangles: `(d1<0, d2<0)` → top, `(d1>0, d2>0)` → bottom, `(d1>0, d2<0)` →
// left, otherwise → right. See `classifyEntryDirection`.

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

export type HoverEdgeDirection = "top" | "right" | "bottom" | "left";

export interface DirectionAwareHoverProps {
  /** Background image source. Defaults to a generic inline placeholder photo. */
  imageSrc?: string;
  imageAlt?: string;
  /** Overlay content (title + subtitle, typically). Defaults to a generic demo caption. */
  children?: DomphyElement[];
  style?: StyleObject;
}

interface DirectionTransform {
  overlayOffCanvas: string;
  imagePan: string;
}

const DIRECTION_TRANSFORMS: Record<HoverEdgeDirection, DirectionTransform> = {
  top: { overlayOffCanvas: "translateY(-100%)", imagePan: "scale(1.08) translateY(3%)" },
  bottom: { overlayOffCanvas: "translateY(100%)", imagePan: "scale(1.08) translateY(-3%)" },
  left: { overlayOffCanvas: "translateX(-100%)", imagePan: "scale(1.08) translateX(3%)" },
  right: { overlayOffCanvas: "translateX(100%)", imagePan: "scale(1.08) translateX(-3%)" },
};

const OVERLAY_ENTERED_TRANSFORM = "translate(0, 0)";
const IMAGE_RESTING_TRANSFORM = "scale(1) translate(0, 0)";

/** Diagonal-slicing entry-direction classification — see the module comment for the math. */
function classifyEntryDirection(centeredX: number, centeredY: number, width: number, height: number): HoverEdgeDirection {
  const ratio = height / (width || 1);
  const d1 = centeredY - ratio * centeredX;
  const d2 = centeredY + ratio * centeredX;
  if (d1 <= 0 && d2 <= 0) return "top";
  if (d1 >= 0 && d2 >= 0) return "bottom";
  if (d1 >= 0 && d2 <= 0) return "left";
  return "right";
}

function defaultImage(): { src: string; alt: string } {
  const markup =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">' +
    '<rect width="400" height="300" fill="#cfd8e3"/>' +
    '<circle cx="320" cy="70" r="34" fill="#eef2f6"/>' +
    '<polygon points="0,300 140,140 210,210 290,110 400,300" fill="#9aa5b1"/>' +
    "</svg>";
  return { src: `data:image/svg+xml,${encodeURIComponent(markup)}`, alt: "Placeholder landscape photo" };
}

function defaultOverlayContent(): DomphyElement[] {
  return [
    { h4: "Whitehaven Beach", $: [heading({ color: "neutral" })] } as DomphyElement,
    { small: "Queensland, Australia", $: [small({ color: "neutral" })] } as DomphyElement,
  ];
}

/**
 * An image card that detects which edge the cursor entered from and pans
 * the image plus slides a text overlay in from that same edge. Call with no
 * arguments for a working demo — a placeholder landscape photo.
 */
function directionAwareHover(props: DirectionAwareHoverProps = {}): DomphyElement<"div"> {
  const defaults = defaultImage();
  const imageSrc = props.imageSrc ?? defaults.src;
  const imageAlt = props.imageAlt ?? defaults.alt;
  const overlayContent = props.children ?? defaultOverlayContent();

  let imageElement: HTMLElement | null = null;
  let overlayElement: HTMLElement | null = null;
  let lastDirection: HoverEdgeDirection | null = null;

  const snapOverlayOffCanvas = (direction: HoverEdgeDirection) => {
    if (!overlayElement) return;
    overlayElement.style.transition = "none";
    overlayElement.style.opacity = "0";
    overlayElement.style.transform = DIRECTION_TRANSFORMS[direction].overlayOffCanvas;
    // Force layout so the snapped position is committed before re-enabling
    // the transition — otherwise the browser would coalesce the snap and the
    // subsequent "entered" state into a single (wrongly animated) frame.
    void overlayElement.offsetHeight;
    overlayElement.style.transition = "";
  };

  const applyOverlayEntered = () => {
    if (!overlayElement) return;
    overlayElement.style.opacity = "1";
    overlayElement.style.transform = OVERLAY_ENTERED_TRANSFORM;
  };

  const imageLayer: DomphyElement<"img"> = {
    img: null,
    src: imageSrc,
    alt: imageAlt,
    _onMount: (node: ElementNode) => {
      imageElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      imageElement = null;
    },
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      display: "block",
      objectFit: "cover",
      transform: IMAGE_RESTING_TRANSFORM,
      transition: "transform 400ms cubic-bezier(0.22, 1, 0.36, 1)",
      willChange: "transform",
    } as StyleObject,
  } as DomphyElement<"img">;

  const overlayLayer = {
    div: [{ div: overlayContent, style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5) } }],
    _onMount: (node: ElementNode) => {
      overlayElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      overlayElement = null;
    },
    dataTone: "shift-16",
    style: {
      position: "absolute",
      insetBlockEnd: themeSpacing(3),
      insetInlineStart: themeSpacing(3),
      padding: themeSpacing(3),
      borderRadius: themeSpacing(2),
      opacity: 0,
      transform: DIRECTION_TRANSFORMS.left.overlayOffCanvas,
      transition: "transform 350ms cubic-bezier(0.22, 1, 0.36, 1), opacity 250ms ease",
      pointerEvents: "none",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [imageLayer, overlayLayer],
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      aspectRatio: "4 / 3",
      cursor: "pointer",
      ...(props.style ?? {}),
    } as StyleObject,
    onPointerEnter: (event: PointerEvent, node: ElementNode) => {
      const rect = (node.domElement as HTMLElement).getBoundingClientRect();
      const width = rect.width || 1;
      const height = rect.height || 1;
      const centeredX = event.clientX - rect.left - width / 2;
      const centeredY = event.clientY - rect.top - height / 2;
      const direction = classifyEntryDirection(centeredX, centeredY, width, height);
      lastDirection = direction;
      // The image pan applies immediately — it always has a transition active,
      // so no snap-without-transition step is needed. Only the overlay's
      // off-canvas snap needs a frame to commit before animating back in
      // (see `snapOverlayOffCanvas`'s own comment).
      if (imageElement) imageElement.style.transform = DIRECTION_TRANSFORMS[direction].imagePan;
      snapOverlayOffCanvas(direction);
      requestAnimationFrame(() => applyOverlayEntered());
    },
    onPointerLeave: () => {
      if (!lastDirection) return;
      if (overlayElement) {
        overlayElement.style.opacity = "0";
        overlayElement.style.transform = DIRECTION_TRANSFORMS[lastDirection].overlayOffCanvas;
      }
      if (imageElement) imageElement.style.transform = IMAGE_RESTING_TRANSFORM;
    },
  };
}

export { directionAwareHover };

← Back to Aceternity UI catalog