animatedTestimonials
A Overlays block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call animatedTestimonials() with no arguments for a working demo, or edit the code below live.
Implementation notes
Two-column layout: an always-mounted photo stack (front photo full, 2 neighbors peeking with alternating-sign tilt/offset/scale, rest hidden) and a crossfading quote/name/role block, both driven by pure reactive per-distance style functions (no motion() unmount needed). Optional autoplay (off by default) plus manual prev/next. Exact rotation degrees/z-index scheme are invented per the spec's own 'not from upstream, treat as design choice' allowance.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Animated Testimonials" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). A two-column testimonial section: a stack of overlapping author
// photos on one side, a crossfading quote/name/role block on the other.
//
// Both halves are always-mounted, absolutely-positioned stacks whose style
// is a pure reactive function of `distance = (index - activeIndex) mod
// total` — the same "compute an offset per depth" idiom `cardStack.ts`
// already establishes in this package, reused here for two independent
// stacks (photos + text) instead of one. No `motion()` enter/exit is
// needed since nothing is ever unmounted; a plain CSS `transition` on each
// stack member's `transform`/`opacity` does the crossfade. Per the spec's
// research note, the exact rotation degrees/z-index scheme aren't from
// upstream — this file alternates a small tilt sign by index parity for
// the "organic feel" the spec describes, a qualitative match rather than
// exact numbers.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { avatar, buttonGhost, heading, small, strong } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";
export interface AnimatedTestimonialItem {
/** Testimonial quote body text. */
quote: string;
/** Author's name. */
name: string;
/** Author's role/designation. */
designation: string;
/** Optional author photo URL. Falls back to initials derived from `name`. */
imageSrc?: string;
}
export interface AnimatedTestimonialsProps {
/** Testimonial items. Defaults to 3 generic demo testimonials. */
testimonials?: AnimatedTestimonialItem[];
/** Auto-advances on an interval. Defaults to `false`. */
autoplay?: boolean;
/** Milliseconds between automatic advances, when `autoplay` is on. Defaults to `5000`. */
intervalMs?: number;
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
const DEFAULT_TESTIMONIALS: AnimatedTestimonialItem[] = [
{
quote:
"Switching to Domphy cut our animation code in half and our bundle size along with it — no framework tax, just the browser doing what it already does well.",
name: "Elena Marsh",
designation: "Staff Engineer, Northlane",
},
{
quote:
"The theme system is the first one I haven't had to fight. Every color, every spacing value, traces back to one token — dark mode was a non-event.",
name: "Rafael Costa",
designation: "Design Systems Lead, Fenwick",
},
{
quote:
"We onboarded three new engineers in a week. Plain objects and patches read like the DOM itself — there's nothing extra to learn.",
name: "Priya Nair",
designation: "Engineering Manager, Solace",
},
];
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();
}
let animatedTestimonialsInstanceCounter = 0;
/**
* A two-column testimonial section that crossfades a stack of author photos
* with a subtle tilt while the quote/name/role text swaps in sync. Call
* with no arguments for a working demo with 3 testimonials.
*/
function animatedTestimonials(props: AnimatedTestimonialsProps = {}): DomphyElement<"div"> {
const instanceId = ++animatedTestimonialsInstanceCounter;
const testimonials = props.testimonials && props.testimonials.length > 0 ? props.testimonials : DEFAULT_TESTIMONIALS;
const totalCount = testimonials.length;
const autoplay = props.autoplay ?? false;
const intervalMs = Math.max(500, props.intervalMs ?? 5000);
const activeIndexState = toState(0, `animated-testimonials-active-${instanceId}`);
const goToIndex = (nextIndex: number) => {
const wrapped = ((nextIndex % totalCount) + totalCount) % totalCount;
activeIndexState.set(wrapped);
};
const goNext = () => goToIndex(activeIndexState.get() + 1);
const goPrevious = () => goToIndex(activeIndexState.get() - 1);
const distanceFor = (listener: Listener, index: number): number =>
(index - activeIndexState.get(listener) + totalCount) % totalCount;
const photoStackElements: DomphyElement<"div">[] = testimonials.map((testimonial, index) => {
const tiltSign = index % 2 === 0 ? 1 : -1;
const computeTransform = (listener: Listener): string => {
const distance = distanceFor(listener, index);
if (distance === 0) return "translateY(0) rotate(0deg) scale(1)";
const clampedDistance = Math.min(distance, 3);
return `translateY(${clampedDistance * 10}px) rotate(${tiltSign * clampedDistance * 5}deg) scale(${1 - clampedDistance * 0.06})`;
};
const computeOpacity = (listener: Listener): number => {
const distance = distanceFor(listener, index);
if (distance === 0) return 1;
if (distance <= 2) return 0.55 - distance * 0.12;
return 0;
};
const computeZIndex = (listener: Listener): number => totalCount - distanceFor(listener, index);
const avatarInner: DomphyElement<"span"> = testimonial.imageSrc
? ({
span: [{ img: null, src: testimonial.imageSrc, alt: testimonial.name } as DomphyElement<"img">],
$: [avatar({ color: "primary" })],
style: { width: "100%", height: "100%" },
} as DomphyElement<"span">)
: ({
span: initialsFromName(testimonial.name),
$: [avatar({ color: "primary" })],
style: { width: "100%", height: "100%" },
} as DomphyElement<"span">);
return {
div: [avatarInner],
_key: `animated-testimonial-photo-${instanceId}-${index}`,
ariaHidden: "true",
style: {
position: "absolute",
inset: 0,
borderRadius: themeSpacing(6),
transition: "transform 500ms cubic-bezier(0.22, 1, 0.36, 1), opacity 400ms ease",
transform: computeTransform,
opacity: computeOpacity,
zIndex: computeZIndex,
} as StyleObject,
} as DomphyElement<"div">;
});
const textStackElements: DomphyElement<"div">[] = testimonials.map((testimonial, index) => {
const computeOpacity = (listener: Listener): number => (activeIndexState.get(listener) === index ? 1 : 0);
const computeTransform = (listener: Listener): string =>
activeIndexState.get(listener) === index ? "translateY(0)" : "translateY(-0.5em)";
const computePointerEvents = (listener: Listener): string =>
activeIndexState.get(listener) === index ? "auto" : "none";
return {
div: [
{ h3: testimonial.quote, $: [heading({ color: "neutral" })] } as DomphyElement,
{
div: [
{ strong: testimonial.name, $: [strong({ color: "neutral" })] } as DomphyElement,
{ small: testimonial.designation, $: [small({ color: "neutral" })] } as DomphyElement,
],
style: { display: "flex", flexDirection: "column", gap: themeSpacing(1), marginTop: themeSpacing(3) } as StyleObject,
} as DomphyElement<"div">,
],
_key: `animated-testimonial-text-${instanceId}-${index}`,
style: {
position: "absolute",
inset: 0,
transition: "opacity 350ms ease, transform 350ms ease",
opacity: computeOpacity,
transform: computeTransform,
pointerEvents: computePointerEvents,
} as StyleObject,
} as DomphyElement<"div">;
});
const photoColumn: DomphyElement<"div"> = {
div: photoStackElements,
style: {
position: "relative",
width: "100%",
aspectRatio: "1 / 1",
} as StyleObject,
} as DomphyElement<"div">;
const textColumn: DomphyElement<"div"> = {
div: [
{ div: textStackElements, style: { position: "relative", minHeight: themeSpacing(48) } as StyleObject } as DomphyElement<"div">,
{
div: [
{
button: "‹",
ariaLabel: "Previous testimonial",
$: [buttonGhost({ color: "neutral" })],
onClick: goPrevious,
style: { borderRadius: "50%", width: themeSpacing(9), height: themeSpacing(9), padding: 0 },
} as DomphyElement<"button">,
{
button: "›",
ariaLabel: "Next testimonial",
$: [buttonGhost({ color: "neutral" })],
onClick: goNext,
style: { borderRadius: "50%", width: themeSpacing(9), height: themeSpacing(9), padding: 0 },
} as DomphyElement<"button">,
],
style: { display: "flex", gap: themeSpacing(3), marginTop: themeSpacing(6) } as StyleObject,
} as DomphyElement<"div">,
],
style: { display: "flex", flexDirection: "column", justifyContent: "center" } as StyleObject,
} as DomphyElement<"div">;
return {
div: [photoColumn, textColumn],
style: {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: themeSpacing(12),
alignItems: "center",
width: "100%",
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined" || !autoplay) return;
const containerElement = node.domElement as HTMLElement | null;
if (!containerElement) return;
let intervalId: ReturnType<typeof setInterval> | null = null;
let intersectionObserver: IntersectionObserver | null = null;
const startCycle = () => {
if (intervalId !== null || totalCount <= 1) return;
intervalId = setInterval(goNext, intervalMs);
};
const stopCycle = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = 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 { animatedTestimonials };