Domphy

expandableCard

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

Implementation notes

Built on @domphy/ui's dialog() patch (native <dialog>, backdrop fade, Escape/outside-click close, focus trap, scroll lock) so the spec's outside-click-detector and scroll-lock research-note items come free and are fully correct. The shared-element morph itself is a Web Animations API tween computed from the clicked card's captured getBoundingClientRect() vs the dialog's laid-out rect (translate+scale identity), not a literal same-DOM-node reparent (Domphy has no layoutId/FLIP primitive) -- reads as a real grow-from-source-card morph in a live browser, and degrades gracefully to a plain fade under jsdom/all-zero-rect environments. Marked partial only because the morph is an approximation of a true shared-element transition, not because any spec behavior is missing.

Status: partial · Reference: Aceternity UI original

// Aceternity UI "Expandable Card" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// row of compact media cards that morph, on click, into an enlarged centered
// panel over a dimmed backdrop.
//
// Built on the `dialog()` ui patch (native `<dialog>`, backdrop, Escape/
// outside-click close, focus trap, scroll lock — the same foundation
// heroVideoDialog.ts uses) rather than a hand-rolled fixed-position overlay,
// so the "outside-click detector" and "body scroll should be locked" bullets
// in the spec's research note come for free. The shared-element "morph" is
// then layered on top: the clicked collapsed card's `getBoundingClientRect()`
// is captured on click, and once the dialog patch has called `showModal()`
// (so the panel has its real, laid-out size), a Web Animations API tween
// plays from a transform that makes the panel exactly overlap the source
// card down to identity — a FLIP animation without a literal shared DOM
// node, since Domphy has no `layoutId`-style primitive to re-parent one.
// Closing reverses the same tween. In a real browser this reads as the small
// card growing into the big panel; under jsdom (all-zero layout rects) the
// scale/translate degrades gracefully to identity — see `computeMorphFrames`.

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

export interface ExpandableCardItem {
  id: string;
  title: string;
  subtitle: string;
  /** Thumbnail/artwork source. Defaults to a themed placeholder panel when omitted. */
  imageSrc?: string;
  description?: string;
  actionLabel?: string;
}

export interface ExpandableCardProps {
  items?: ExpandableCardItem[];
  onOpen?: (id: string) => void;
  onClose?: () => void;
  style?: StyleObject;
}

const DEFAULT_ITEMS: ExpandableCardItem[] = [
  {
    id: "midnight-transit",
    title: "Midnight Transit",
    subtitle: "Lower Bay Collective",
    description: "A slow-building instrumental set recorded live across three cities over one tour.",
    actionLabel: "Play",
  },
  {
    id: "paper-lanterns",
    title: "Paper Lanterns",
    subtitle: "Nadia Voss",
    description: "Six tracks written during a single rainy week, mixed almost entirely in one take.",
    actionLabel: "Play",
  },
  {
    id: "glass-orchard",
    title: "Glass Orchard",
    subtitle: "Ferro & Wren",
    description: "A collaborative EP blending analog synths with field recordings from an orchard at dawn.",
    actionLabel: "Play",
  },
];

function playGlyph(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [{ polygon: null, points: "9,6 20,12 9,18" }],
        viewBox: "0 0 24 24",
        fill: "currentColor",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: { display: "inline-flex", width: themeSpacing(3.5), height: themeSpacing(3.5) },
  };
}

function closeGlyph(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [
          { line: null, x1: "6", y1: "6", x2: "18", y2: "18" },
          { line: null, x1: "18", y1: "6", x2: "6", y2: "18" },
        ],
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: { display: "inline-flex", width: themeSpacing(4), height: themeSpacing(4) },
  };
}

/** Themed placeholder artwork panel, used whenever an item has no `imageSrc`. */
function placeholderArtwork(item: ExpandableCardItem): DomphyElement<"div"> {
  return {
    div: [{ small: item.title.slice(0, 2).toUpperCase(), $: [small({ color: "neutral" })] }],
    ariaHidden: "true",
    dataTone: "shift-3",
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      width: "100%",
      aspectRatio: "1 / 1",
      borderRadius: themeSpacing(3),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    },
  } as DomphyElement<"div">;
}

function artwork(item: ExpandableCardItem): DomphyElement {
  if (!item.imageSrc) return placeholderArtwork(item);
  return {
    img: null,
    src: item.imageSrc,
    alt: item.title,
    style: {
      display: "block",
      width: "100%",
      aspectRatio: "1 / 1",
      objectFit: "cover",
      borderRadius: themeSpacing(3),
    },
  } as DomphyElement<"img">;
}

/** Distance (px) the morph's initial transform should collapse from — 0 when either
 * rect is degenerate (e.g. jsdom's all-zero layout), which safely falls back to identity. */
function computeMorphFrames(sourceRect: DOMRect, targetRect: DOMRect): { from: string; to: string } {
  const identity = "translate(0px, 0px) scale(1, 1)";
  if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0) {
    return { from: identity, to: identity };
  }
  const scaleX = sourceRect.width / targetRect.width;
  const scaleY = sourceRect.height / targetRect.height;
  const sourceCenterX = sourceRect.left + sourceRect.width / 2;
  const sourceCenterY = sourceRect.top + sourceRect.height / 2;
  const targetCenterX = targetRect.left + targetRect.width / 2;
  const targetCenterY = targetRect.top + targetRect.height / 2;
  const translateX = sourceCenterX - targetCenterX;
  const translateY = sourceCenterY - targetCenterY;
  return {
    from: `translate(${translateX.toFixed(2)}px, ${translateY.toFixed(2)}px) scale(${scaleX.toFixed(4)}, ${scaleY.toFixed(4)})`,
    to: identity,
  };
}

/**
 * A row of compact media cards that morph into an enlarged centered panel on
 * click, over a dimmed, Escape/outside-click-closeable backdrop. Call with no
 * arguments for a working demo — 3 generic "album" cards.
 */
function expandableCard(props: ExpandableCardProps = {}): DomphyElement<"div"> {
  const items = props.items && props.items.length > 0 ? props.items : DEFAULT_ITEMS;

  const open = toState(false);
  const activeId = toState<string | null>(null);
  let sourcePanelRect: DOMRect | null = null;
  let panelElement: HTMLElement | null = null;

  const activeItem = (listener?: Listener): ExpandableCardItem =>
    items.find((item) => item.id === activeId.get(listener)) ?? items[0];

  const openFromCard = (id: string, cardElement: HTMLElement) => {
    sourcePanelRect = cardElement.getBoundingClientRect();
    activeId.set(id);
    open.set(true);
    props.onOpen?.(id);
  };

  const closePanel = () => {
    open.set(false);
    props.onClose?.();
  };

  const collapsedCards: DomphyElement<"button">[] = items.map((item) => ({
    button: [
      artwork(item),
      {
        div: [
          { small: item.title, $: [small({ color: "neutral" })] },
          { small: item.subtitle, $: [small({ color: "neutral" })] },
        ],
        style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5), textAlign: "start" },
      } as DomphyElement<"div">,
    ],
    _key: item.id,
    type: "button",
    ariaLabel: `Open ${item.title}`,
    onClick: (_event: MouseEvent, node: ElementNode) => openFromCard(item.id, node.domElement as HTMLElement),
    style: {
      display: "flex",
      flexDirection: "column",
      gap: themeSpacing(2),
      width: themeSpacing(36),
      padding: themeSpacing(3),
      border: "none",
      borderRadius: themeSpacing(3),
      cursor: "pointer",
      textAlign: "start",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      transition: "background-color 150ms ease",
      "&:hover": { backgroundColor: (listener: Listener) => themeColor(listener, "increase-1") },
    } as StyleObject,
  }) as DomphyElement<"button">);

  const closeButton: DomphyElement<"button"> = {
    button: [closeGlyph()],
    ariaLabel: "Close",
    type: "button",
    onClick: closePanel,
    dataTone: "shift-0",
    style: {
      position: "absolute",
      insetBlockStart: themeSpacing(3),
      insetInlineEnd: themeSpacing(3),
      width: themeSpacing(9),
      height: themeSpacing(9),
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      borderRadius: "50%",
      border: "none",
      cursor: "pointer",
      zIndex: 1,
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      boxShadow: (listener: Listener) => `0 ${themeSpacing(1)} ${themeSpacing(4)} ${themeColor(listener, "shift-4", "neutral")}`,
    },
  };

  // Every text node below reads `activeItem(listener)` (forwarding its own
  // listener) so the panel's content stays in sync if a caller re-opens a
  // different item while already open, without needing a fresh dialog mount.
  const expandedBody: DomphyElement<"div"> = {
    div: [
      {
        div: (listener: Listener) => [artwork(activeItem(listener))],
        style: { width: "100%" },
      } as DomphyElement<"div">,
      {
        div: [
          { h3: (listener: Listener) => activeItem(listener).title, $: [heading({ color: "neutral" })] } as DomphyElement,
          { p: (listener: Listener) => activeItem(listener).subtitle, $: [paragraph({ color: "neutral" })] } as DomphyElement,
          {
            p: (listener: Listener) => activeItem(listener).description ?? "",
            $: [paragraph({ color: "neutral" })],
          } as DomphyElement,
        ],
        style: { display: "flex", flexDirection: "column", gap: themeSpacing(2), padding: themeSpacing(6) },
      } as DomphyElement<"div">,
      {
        button: [playGlyph(), { span: (listener: Listener) => activeItem(listener).actionLabel ?? "Play" } as DomphyElement],
        type: "button",
        // Its own edge-anchor surface (rather than inheriting the panel's dark
        // `shift-17` context) so the button reads as a proper filled pill with
        // legible text, not a `shift-0`-on-`shift-17` near-invisible label.
        dataTone: "shift-0",
        style: {
          display: "inline-flex",
          alignItems: "center",
          gap: themeSpacing(2),
          alignSelf: "flex-start",
          marginInlineStart: themeSpacing(6),
          marginBlockEnd: themeSpacing(6),
          padding: `${themeSpacing(2)} ${themeSpacing(5)}`,
          border: "none",
          borderRadius: themeSpacing(8),
          cursor: "pointer",
          backgroundColor: (listener: Listener) => themeColor(listener, "inherit", "primary"),
          color: (listener: Listener) => themeColor(listener, "shift-9", "primary"),
        } as StyleObject,
      } as DomphyElement<"button">,
    ],
    style: { position: "relative", display: "flex", flexDirection: "column" },
  } as DomphyElement<"div">;

  const dialogElement: DomphyElement<"dialog"> = {
    dialog: [closeButton, expandedBody],
    $: [dialog({ open, color: "neutral" })],
    ariaLabel: "Expanded card",
    dataTone: "shift-17",
    _onMount: (node: ElementNode) => {
      panelElement = node.domElement as HTMLElement;
      const applyMorph = (isOpen: boolean) => {
        if (!panelElement || typeof panelElement.animate !== "function") return;
        if (isOpen && sourcePanelRect) {
          const targetRect = panelElement.getBoundingClientRect();
          const { from, to } = computeMorphFrames(sourcePanelRect, targetRect);
          panelElement.animate([{ transform: from }, { transform: to }], {
            duration: 320,
            easing: "cubic-bezier(0.22, 1, 0.36, 1)",
            fill: "forwards",
          });
        } else if (!isOpen && sourcePanelRect) {
          const currentRect = panelElement.getBoundingClientRect();
          const { from } = computeMorphFrames(sourcePanelRect, currentRect);
          panelElement.animate([{ transform: "translate(0px, 0px) scale(1, 1)" }, { transform: from }], {
            duration: 260,
            easing: "cubic-bezier(0.4, 0, 0.2, 1)",
            fill: "forwards",
          });
        }
      };
      // Registered after dialog()'s own `_onMount` listener (patch listeners
      // merge first, native element listeners chain after — see
      // packages/core/src/helpers.ts `addHook`), so `showModal()`/`close()`
      // have already run and the panel is laid out before we measure it.
      const release = open.addListener(applyMorph);
      node.addHook("Remove", () => release());
    },
    style: {
      position: "relative",
      padding: 0,
      border: "none",
      overflow: "hidden",
      borderRadius: themeSpacing(5),
      width: "min(90vw, 32em)",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    },
  };

  return {
    div: [
      { div: collapsedCards, style: { display: "flex", flexWrap: "wrap", gap: themeSpacing(3) } } as DomphyElement<"div">,
      dialogElement,
    ],
    style: { position: "relative", ...(props.style ?? {}) },
  };
}

export { expandableCard };

← Back to Aceternity UI catalog