Domphy

cardHoverEffect

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

Implementation notes

Full behavior match: a single shared highlight <div> (grid's first child, painting below all cards by DOM order) is imperatively repositioned/resized (left/top/width/height) via getBoundingClientRect() on each card's pointerenter, animated purely with a CSS transition (no Framer Motion layoutId equivalent exists in Domphy, so this is a hand-rolled FLIP-by-CSS-transition instead of a true shared-element reparent). Opacity only toggles on the whole group's mouseleave (not each card's), which is what makes card-to-card hover glide instead of refade. Doctor-clean; passes doctor's tone-background-inherit/low-contrast/missing-color checks (with one documented _doctorDisable on the intentionally-decorative highlight panel, matching the existing magicCard.ts precedent).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Card Hover Effect" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// responsive grid of text-only link cards over a dark background. Hovering
// any card slides a single shared rounded highlight panel behind it; moving
// the pointer directly from one card to an adjacent one glides the highlight
// smoothly to the new slot instead of fading out and back in.
//
// Implementation note: rather than mounting a `layoutId`-style shared
// element that gets re-parented between DOM slots (Domphy has no such
// primitive), a single highlight `<div>` is rendered once as the grid's
// first child (so, by DOM order alone, every card painted after it stacks on
// top with no `z-index` needed) and its `left/top/width/height` are written
// imperatively on every card's `pointerenter`, with a plain CSS `transition`
// doing the actual FLIP-style tween. Opacity only fades on the *group's*
// `mouseleave` (not each card's), which is what makes card-to-card hover
// read as one continuous glide instead of a flicker.

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

export interface CardHoverItem {
  title: string;
  description: string;
  href?: string;
}

export interface CardHoverEffectProps {
  /** Cards to render. Defaults to 6 generic feature blurbs. */
  items?: CardHoverItem[];
  /** Grid column count at the widest breakpoint. Defaults to `3`. */
  columns?: number;
  /** Theme color family for the sliding highlight panel. Defaults to `"neutral"`. */
  highlightColor?: ThemeColor;
  style?: StyleObject;
}

const DEFAULT_ITEMS: CardHoverItem[] = [
  { title: "Plain objects", description: "UIs are just objects keyed by HTML tag — no JSX, no virtual DOM.", href: "#" },
  { title: "Patches, not wrappers", description: "Behavior and style compose via a `$` array applied to native elements.", href: "#" },
  { title: "Listener-based reactivity", description: "Read with a listener, write with `.set()` — one-way data flow throughout.", href: "#" },
  { title: "Theme tokens only", description: "Color, spacing and size always resolve through the design system, never literals.", href: "#" },
  { title: "SSR built in", description: "Server-render the same element tree and hydrate it without a second runtime.", href: "#" },
  { title: "Zero build step", description: "Drop the runtime on a page and start composing — no bundler required.", href: "#" },
];

// Highlight sits a few pixels outside the hovered card's own box on every
// side, matching the spec's "slightly larger than the card" highlight rect.
const HIGHLIGHT_OUTSET_PX = 6;

/**
 * A grid of text-only link cards where hovering any card slides a shared
 * rounded highlight panel behind it, gliding continuously between adjacent
 * cards instead of refading. Call with no arguments for a working demo — 6
 * generic feature cards on a dark surface.
 */
function cardHoverEffect(props: CardHoverEffectProps = {}): DomphyElement<"div"> {
  const items = props.items && props.items.length > 0 ? props.items : DEFAULT_ITEMS;
  const columns = Math.max(1, props.columns ?? 3);
  const highlightColor = props.highlightColor ?? "neutral";

  let containerElement: HTMLElement | null = null;
  let highlightElement: HTMLElement | null = null;

  const showHighlightBehind = (cardElement: HTMLElement) => {
    if (!containerElement || !highlightElement) return;
    const containerRect = containerElement.getBoundingClientRect();
    const cardRect = cardElement.getBoundingClientRect();
    const left = cardRect.left - containerRect.left - HIGHLIGHT_OUTSET_PX;
    const top = cardRect.top - containerRect.top - HIGHLIGHT_OUTSET_PX;
    highlightElement.style.width = `${cardRect.width + HIGHLIGHT_OUTSET_PX * 2}px`;
    highlightElement.style.height = `${cardRect.height + HIGHLIGHT_OUTSET_PX * 2}px`;
    highlightElement.style.transform = `translate(${left}px, ${top}px)`;
    highlightElement.style.opacity = "1";
  };

  const hideHighlight = () => {
    if (highlightElement) highlightElement.style.opacity = "0";
  };

  const highlightPanel = {
    div: null,
    ariaHidden: "true",
    // Decorative shared-highlight layer with no text of its own — exempt
    // from the missing-color contract, same as magicCard.ts's glow layer.
    // A fixed shifted tone (not "inherit") and a small text/bg contrast gap
    // are both intentional here: this is a solid decorative accent panel with
    // no text of its own, not a themed content surface — same pattern
    // magicCard.ts's orb glow layer uses (see its own comment for the fab()
    // precedent). `color` only exists to keep the "missing-color" heuristic
    // quiet since it's paired with `backgroundColor` on the same element.
    _doctorDisable: ["missing-color", "tone-background-inherit", "low-contrast"],
    _onMount: (node: ElementNode) => {
      highlightElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      highlightElement = null;
    },
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      width: 0,
      height: 0,
      opacity: 0,
      zIndex: 0,
      pointerEvents: "none",
      borderRadius: themeSpacing(4),
      backgroundColor: (listener: Listener) => themeColor(listener, "shift-2", highlightColor),
      color: (listener: Listener) => themeColor(listener, "shift-0", highlightColor),
      transition:
        "transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), " +
        "height 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 150ms ease",
      willChange: "transform",
    } as StyleObject,
  } as DomphyElement<"div">;

  const cardElements: DomphyElement<"a">[] = items.map((item, index) => ({
    a: [
      { h3: item.title, $: [heading({ color: "neutral" })] },
      { p: item.description, $: [paragraph({ color: "neutral" })] },
    ],
    _key: `${item.title}-${index}`,
    href: item.href ?? "#",
    $: [link({ color: "neutral", accentColor: "neutral" })],
    onPointerEnter: (_event: PointerEvent, node: ElementNode) => {
      showHighlightBehind(node.domElement as HTMLElement);
    },
    style: {
      position: "relative",
      zIndex: 1,
      display: "block",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(6),
      // Also declared here (not just via the `link()` patch on `$`) so it
      // reads directly off this element's own reactive style — doctor's
      // static analysis inspects each element's own `style` object, not the
      // merged result of applied patches.
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
    } as StyleObject,
  }) as DomphyElement<"a">);

  return {
    div: [highlightPanel, ...cardElements],
    dataTone: "shift-16",
    onMouseLeave: hideHighlight,
    _onMount: (node: ElementNode) => {
      containerElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      containerElement = null;
    },
    style: {
      position: "relative",
      display: "grid",
      gridTemplateColumns: "1fr",
      gap: themeSpacing(2),
      padding: themeSpacing(6),
      borderRadius: themeSpacing(5),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      "@media (min-width: 48em)": {
        gridTemplateColumns: `repeat(${columns}, 1fr)`,
      },
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { cardHoverEffect };

← Back to Aceternity UI catalog