cardStack
A Cards block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call cardStack() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full implementation: fixed DOM nodes (no list-reorder churn) each holding a depth state that an IntersectionObserver-gated setInterval advances every cycle, tweened via @domphy/ui's motion() (Web Animations API) with a cubic-bezier(0.34,1.56,0.64,1) 'back-ease' curve approximating the spec's spring-style overshoot-on-settle — Domphy's motion() has no dedicated spring-physics integrator, only WAAPI easing curves, so this is the closest available approximation rather than a skipped feature. z-index snaps immediately per depth change (not tweened) so the departing front card visibly tucks under the deck as it slides back.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Card Stack" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// vertically stacked pile of testimonial-style cards where the front card
// periodically animates to the back, cycling through all items in a
// continuous loop.
//
// Every card is a fixed DOM node (no list reordering/`_key` churn) that
// simply owns a `State<number>` "depth" (0 = front) and a companion
// `State<MotionKeyframe>` fed straight into `@domphy/ui`'s `motion()`
// patch — depth -> `{ y, x, scale, opacity }` via `depthToKeyframe()`. A
// `setInterval` (paused while off-screen via `IntersectionObserver`, same
// idiom this package's other continuous loops use) advances every card's
// depth by one slot each tick, wrapping the front card back to the last
// slot — `motion()`'s Web Animations tween (a slight "back-ease" overshoot
// curve) handles the actual slide/shrink/settle, so this file only ever
// computes target keyframes, never runs its own animation loop. `z-index`
// is a separate, non-animated reactive style bound to the same depth
// state, so stacking order snaps the instant a card starts moving back —
// it reads as "tucking under the deck" rather than a visual glitch.
import type { DomphyElement, ElementNode, Listener, State, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { avatar, motion, paragraph, small, strong } from "@domphy/ui";
import type { MotionKeyframe } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface CardStackItem {
/** Testimonial/quote body text. */
quote: string;
/** Person's name. */
name: string;
/** Person's role/designation. */
role: string;
/** Optional avatar image URL. Falls back to initials derived from `name`. */
avatarSrc?: string;
}
export interface CardStackProps {
/** Testimonial items, front-to-back initial order. Defaults to 4 demo testimonials. */
items?: CardStackItem[];
/** Vertical stagger offset per depth level, in px. Defaults to `14`. */
offsetPx?: number;
/** Scale-down factor applied per depth level (fraction subtracted from `1`). Defaults to `0.06`. */
scaleStep?: number;
/** Milliseconds between automatic cycles. Defaults to `4000`. */
intervalMs?: number;
/** Card surface color family. Defaults to `"neutral"`. */
color?: ThemeColor;
/** Passthrough style merged onto the outer stack container. */
style?: StyleObject;
}
const DEFAULT_ITEMS: CardStackItem[] = [
{
quote:
"This is hands-down the smoothest UI toolkit I've shipped with — the depth and motion feel expensive without any of the usual animation-library overhead.",
name: "Maya Chen",
role: "Product Designer, Northwind",
},
{
quote:
"We swapped three different animation libraries for one patch. Onboarding new engineers got a whole lot easier once the UI stopped fighting the framework.",
name: "Daniel Osei",
role: "Frontend Lead, Fenwick Labs",
},
{
quote:
"The stacked-card cycling effect alone sold our design team — it's the kind of detail that makes a landing page feel alive without being distracting.",
name: "Priya Raman",
role: "Growth Marketer, Solace",
},
{
quote:
"Every effect in this library composes cleanly with the rest of our design system instead of fighting it. That's rarer than it should be.",
name: "Tomás Ibarra",
role: "Engineering Manager, Vantpoint",
},
];
function initialsFromName(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
const first = parts[0]!.charAt(0);
const last = parts.length > 1 ? parts[parts.length - 1]!.charAt(0) : "";
return (first + last).toUpperCase();
}
function depthToKeyframe(depth: number, offsetPx: number, scaleStep: number, totalCount: number): MotionKeyframe {
const clampedDepth = Math.min(depth, Math.max(0, totalCount - 1));
return {
x: clampedDepth * offsetPx * 0.18,
y: clampedDepth * offsetPx,
scale: Math.max(0.7, 1 - clampedDepth * scaleStep),
opacity: clampedDepth === 0 ? 1 : Math.max(0.35, 1 - clampedDepth * 0.14),
};
}
interface StackCardRuntime {
depthState: State<number>;
motionState: State<MotionKeyframe>;
}
let cardStackInstanceCounter = 0;
/**
* A vertically stacked pile of testimonial cards that continuously cycles
* the front card to the back, looping through every item. Call with no
* arguments for a working demo with 4 testimonials.
*/
function cardStack(props: CardStackProps = {}): DomphyElement<"div"> {
const instanceId = ++cardStackInstanceCounter;
const items = props.items && props.items.length > 0 ? props.items : DEFAULT_ITEMS;
const totalCount = items.length;
const offsetPx = props.offsetPx ?? 14;
const scaleStep = props.scaleStep ?? 0.06;
const intervalMs = Math.max(200, props.intervalMs ?? 4000);
const color = props.color ?? "neutral";
const runtimes: StackCardRuntime[] = items.map((_item, index) => {
const depthState = toState(index, `card-stack-depth-${instanceId}-${index}`);
const motionState = toState<MotionKeyframe>(
depthToKeyframe(index, offsetPx, scaleStep, totalCount),
`card-stack-motion-${instanceId}-${index}`,
);
return { depthState, motionState };
});
const cardElements: DomphyElement<"div">[] = items.map((item, index) => {
const runtime = runtimes[index]!;
const avatarElement: DomphyElement<"span"> = item.avatarSrc
? ({
span: [{ img: null, src: item.avatarSrc, alt: "", ariaHidden: "true" } as DomphyElement<"img">],
$: [avatar({ color })],
} as DomphyElement<"span">)
: ({ span: initialsFromName(item.name), $: [avatar({ color })] } as DomphyElement<"span">);
return {
div: [
{ p: item.quote, $: [paragraph({ color: "neutral" })] } as DomphyElement,
{
footer: [
avatarElement,
{
div: [
{ strong: item.name, $: [strong({ color: "neutral" })] } as DomphyElement,
{ small: item.role, $: [small({ color: "neutral" })] } as DomphyElement,
],
style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5) } as StyleObject,
} as DomphyElement<"div">,
],
style: { display: "flex", alignItems: "center", gap: themeSpacing(3) } as StyleObject,
} as DomphyElement<"footer">,
],
_key: `card-stack-item-${instanceId}-${index}`,
$: [
motion({
animate: runtime.motionState,
transition: { duration: 700, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" },
}),
],
style: {
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: themeSpacing(6),
borderRadius: themeSpacing(4),
transformOrigin: "top center",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
color: (listener: Listener) => themeColor(listener, "shift-9", color),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3", color)}`,
outlineOffset: "-1px",
boxShadow: (listener: Listener) =>
`0 ${themeSpacing(3)} ${themeSpacing(8)} ${themeColor(listener, "shift-4", color)}`,
zIndex: (listener: Listener) => totalCount - runtime.depthState.get(listener),
} as StyleObject,
} as DomphyElement<"div">;
});
return {
div: cardElements,
style: {
position: "relative",
width: "100%",
maxWidth: themeSpacing(120),
marginInline: "auto",
height: `calc(${themeSpacing(72)} + ${(totalCount - 1) * offsetPx}px)`,
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const containerElement = node.domElement as HTMLElement | null;
if (!containerElement) return;
const advanceStack = () => {
for (const runtime of runtimes) {
const currentDepth = runtime.depthState.get();
const nextDepth = currentDepth === 0 ? totalCount - 1 : currentDepth - 1;
runtime.depthState.set(nextDepth);
runtime.motionState.set(depthToKeyframe(nextDepth, offsetPx, scaleStep, totalCount));
}
};
let intervalId: ReturnType<typeof setInterval> | null = null;
const startCycle = () => {
if (intervalId !== null || totalCount <= 1) return;
intervalId = setInterval(advanceStack, intervalMs);
};
const stopCycle = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
let intersectionObserver: IntersectionObserver | null = null;
if (typeof IntersectionObserver === "function") {
intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) startCycle();
else stopCycle();
}
});
intersectionObserver.observe(containerElement);
} else {
startCycle();
}
node.addHook("Remove", () => {
stopCycle();
intersectionObserver?.disconnect();
});
},
} as DomphyElement<"div">;
}
export { cardStack };