layoutMotionCards
A Labs block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call layoutMotionCards() with no arguments for a working demo, or edit the code below live.
Implementation notes
Scattered/rotated card grid straightens+scales into a centered, enlarged state on hover or click (single continuous transform/left/top transition carries scale+rotation+position together, matching the spec's 'one physical card scaling up' framing). A true FLIP/getBoundingClientRect() shared-layout measurement was deliberately not used -- both the resting and expanded geometries are fully known in advance, so a reactive style write (applied both synchronously on interaction and via a State listener for external control) is simpler and equally correct. 'Sibling cards shift aside to make room' is approximated via scale-down + dim rather than literal position reflow, since the source spec itself flags the exact expand mechanics as inferred (live demo interaction was never observed).
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Interface Crafts Cards" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). A loosely scattered grid of colorful poster cards that
// straighten and scale up into an enlarged, centered state on interaction.
//
// The spec's own research note flags the expand mechanics as inferred (the
// live demo's interaction never finished loading during research) — a
// "measure both rects and hand-roll a FLIP tween" implementation (the
// technique expandableCard.ts uses to morph a card into a dialog) was
// deliberately NOT used here, because that technique exists to bridge two
// otherwise-unrelated DOM subtrees (a card and a portal-ed dialog). Here the
// "before" and "after" states are the *same* element, and both are fully
// known in advance (`restLeftPercent`/`restTopPercent`/`restRotationDeg` vs.
// dead-center), so a single `applyActiveState` writes `left`/`top`/`transform`/
// `opacity`/`zIndex` straight to each card's DOM node — the same imperative-
// on-interaction tradeoff focusCards.ts/cardHoverEffect.ts make elsewhere in
// this package for continuous, purely visual pointer-driven state, backed by
// one static CSS `transition` declared once per card. `transform` alone
// carries scale + rotation + the final centering nudge, so all of it reads
// as one continuous move, matching the spec's "position, rotation and size
// animate together" framing. `activeIndex` stays the single source of truth
// (so a caller-controlled `State` — or `onActiveChange` — works too) via one
// listener subscription that re-runs the same `applyActiveState` sweep.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState, type ValueOrState } from "@domphy/core";
import { heading } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface LayoutMotionCardItem {
id: string;
title: string;
/** Photo/thumbnail source. Defaults to a themed gradient placeholder when omitted. */
imageSrc?: string;
/** Flat accent family for the card surface. Defaults to a themed rotation of families. */
colorFamily?: ThemeColor;
/** Edge-anchor tone for the card surface (`shift-0`-`shift-3` for a light chip, `shift-14`-`shift-17`
* for a dark one). Defaults to a themed rotation matching `colorFamily`. */
surfaceTone?: `shift-${number}`;
/** Resting position, as a percentage of the scene box. */
restLeftPercent?: number;
restTopPercent?: number;
/** Resting rotation, in degrees. */
restRotationDeg?: number;
}
export interface LayoutMotionCardsProps {
items?: LayoutMotionCardItem[];
/** Which card (if any) is expanded. Pass a `State<number|null>` for controlled external control
* (hover/click still update it). Defaults to `null` (nothing expanded). */
activeIndex?: ValueOrState<number | null>;
/** What activates a card. Defaults to `"hover"`. */
trigger?: "hover" | "click";
/** Resting card width, in `themeSpacing` units. Defaults to `26` (portrait, `3 / 4` aspect ratio). */
cardWidthUnits?: number;
/** Expanded card scale, relative to its resting width. Defaults to `1.8`. */
expandedScale?: number;
onActiveChange?: (index: number | null) => void;
style?: StyleObject;
}
interface ResolvedSurface {
family: ThemeColor;
tone: `shift-${number}`;
}
interface ResolvedCard {
item: LayoutMotionCardItem;
surface: ResolvedSurface;
restLeft: number;
restTop: number;
restRotation: number;
}
const SURFACE_ROTATION: ResolvedSurface[] = [
{ family: "attention", tone: "shift-2" },
{ family: "neutral", tone: "shift-1" },
{ family: "info", tone: "shift-15" },
{ family: "secondary", tone: "shift-14" },
{ family: "neutral", tone: "shift-16" },
];
const DEFAULT_ITEMS: LayoutMotionCardItem[] = [
{ id: "working-knowledge", title: "Working Knowledge", restLeftPercent: 4, restTopPercent: 12, restRotationDeg: -8 },
{ id: "practical-demonstration", title: "Practical Demonstration", restLeftPercent: 22, restTopPercent: 4, restRotationDeg: 5 },
{ id: "collaborate-with-ai", title: "Collaborate with AI", restLeftPercent: 41, restTopPercent: 15, restRotationDeg: -3 },
{ id: "means-and-methods", title: "Means & Methods", restLeftPercent: 59, restTopPercent: 3, restRotationDeg: 8 },
{ id: "interface-kit", title: "Interface Kit", restLeftPercent: 76, restTopPercent: 13, restRotationDeg: -6 },
];
function cardMedia(item: LayoutMotionCardItem): DomphyElement<"div"> {
if (item.imageSrc) {
return {
div: null,
style: { position: "absolute", inset: 0, backgroundImage: `url(${item.imageSrc})`, backgroundSize: "cover", backgroundPosition: "center" },
} as DomphyElement<"div">;
}
return {
div: null,
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
insetBlockStart: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
blockSize: "66%",
backgroundImage: (listener: Listener) => `linear-gradient(150deg, transparent, ${themeColor(listener, "shift-6", "neutral")})`,
},
} as DomphyElement<"div">;
}
function restTransform(card: ResolvedCard, dimmed: boolean): string {
const scale = dimmed ? 0.94 : 1;
return `translate(0, 0) scale(${scale}) rotate(${card.restRotation}deg)`;
}
/**
* A loosely scattered grid of colorful poster cards that straighten and
* scale up into an enlarged, centered state on hover or click. Call with no
* arguments for a working demo — 5 generic themed cards.
*/
function layoutMotionCards(props: LayoutMotionCardsProps = {}): DomphyElement<"div"> {
const items = props.items && props.items.length > 0 ? props.items : DEFAULT_ITEMS;
const trigger = props.trigger ?? "hover";
const cardWidthUnits = props.cardWidthUnits ?? 26;
const expandedScale = props.expandedScale ?? 1.8;
const activeIndex = toState<number | null>(props.activeIndex ?? null);
const cardElements: (HTMLElement | null)[] = items.map(() => null);
const resolvedCards: ResolvedCard[] = items.map((item, index) => ({
item,
surface: { ...SURFACE_ROTATION[index % SURFACE_ROTATION.length], ...(item.colorFamily ? { family: item.colorFamily } : {}), ...(item.surfaceTone ? { tone: item.surfaceTone } : {}) },
restLeft: item.restLeftPercent ?? 0,
restTop: item.restTopPercent ?? 0,
restRotation: item.restRotationDeg ?? 0,
}));
const applyActiveState = (active: number | null) => {
resolvedCards.forEach((card, index) => {
const element = cardElements[index];
if (!element) return;
const isActive = active === index;
const isDimmed = active !== null && !isActive;
element.style.left = isActive ? "50%" : `${card.restLeft}%`;
element.style.top = isActive ? "50%" : `${card.restTop}%`;
element.style.zIndex = isActive ? "30" : String(index + 1);
element.style.opacity = isDimmed ? "0.6" : "1";
element.style.transform = isActive ? `translate(-50%, -50%) scale(${expandedScale}) rotate(0deg)` : restTransform(card, isDimmed);
});
};
const setActive = (index: number | null) => {
activeIndex.set(index);
// Applied synchronously too (not just via the `addListener` subscription below, which
// flushes on a microtask) so hover/click feels immediate — re-applying the same value
// a tick later via the listener is a harmless no-op, and keeps a caller-controlled
// `State` (mutated directly, bypassing `setActive`) in sync as well.
applyActiveState(index);
props.onActiveChange?.(index);
};
const cardTrees: DomphyElement<"div">[] = resolvedCards.map((card, index) => ({
div: [
cardMedia(card.item),
{
div: [{ h3: card.item.title, $: [heading({ color: "neutral" })] }],
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
insetBlockEnd: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
padding: themeSpacing(3),
},
} as DomphyElement<"div">,
],
_key: card.item.id,
role: "button",
tabindex: 0,
ariaLabel: card.item.title,
onMouseEnter: () => {
if (trigger === "hover") setActive(index);
},
onClick: () => {
if (trigger !== "click") return;
setActive(activeIndex.get() === index ? null : index);
},
dataTone: card.surface.tone,
_onMount: (node: ElementNode) => {
const element = node.domElement as HTMLElement;
cardElements[index] = element;
element.style.left = `${card.restLeft}%`;
element.style.top = `${card.restTop}%`;
element.style.zIndex = String(index + 1);
element.style.transform = restTransform(card, false);
},
_onRemove: () => {
cardElements[index] = null;
},
style: {
position: "absolute",
inlineSize: themeSpacing(cardWidthUnits),
aspectRatio: "3 / 4",
overflow: "hidden",
cursor: "pointer",
borderRadius: themeSpacing(4),
opacity: 1,
transition:
"left 420ms cubic-bezier(0.22, 1, 0.36, 1), top 420ms cubic-bezier(0.22, 1, 0.36, 1), " +
"transform 420ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", card.surface.family),
color: (listener: Listener) => themeColor(listener, "shift-9", card.surface.family),
} as StyleObject,
})) as DomphyElement<"div">[];
return {
div: cardTrees,
onMouseLeave: () => {
if (trigger === "hover") setActive(null);
},
_onMount: (node: ElementNode) => {
const release = activeIndex.addListener((active: number | null) => applyActiveState(active));
node.addHook("Remove", () => release());
},
style: {
position: "relative",
width: "100%",
minBlockSize: themeSpacing(90),
...(props.style ?? {}),
} as StyleObject,
};
}
export { layoutMotionCards };