carousel
A Overlays block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call carousel() with no arguments for a working demo, or edit the code below live.
Implementation notes
Rounded photo-card slides with gradient+headline+pill CTA overlay, round prev/next controls, click-to-navigate on partially-visible neighbors, and a pure-CSS &:hover [data-attr] icon nudge on the CTA. Neighbor de-emphasis (scale 0.86, opacity 0.55/0) uses invented-but-qualitatively-matching numbers, exactly as the spec's research note pre-authorized ('exact numbers weren't exposed... aim for the qualitative effect').
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Carousel" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// horizontal carousel of large rounded photo cards, each with a headline
// and pill CTA overlay, navigated via round previous/next controls.
//
// Every slide is a fixed, always-mounted DOM node (no keyed insert/remove
// churn) positioned `absolute` and driven purely by one reactive style
// function of `distance = index - activeIndex` — the same "stack of
// cards, computed offset per depth" idiom `cardStack.ts` already
// establishes in this package. Softened/scaled neighbors and a hidden
// far tier fall out of that single function; clicking a neighbor jumps
// straight to it (`activeIndex.set(index)`), and the CTA icon's hover
// nudge is a plain nested `&:hover` CSS rule — no JS needed for that part.
// Per the spec's research note, the exact de-emphasis numbers (scale/
// opacity for non-active neighbors) are not from upstream; this file picks
// values that read as "centered slide sharp, neighbors softened" per the
// spec's qualitative description.
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { buttonGhost, heading } from "@domphy/ui";
import { themeColor, themeSize, themeSpacing } from "@domphy/theme";
export interface CarouselSlideItem {
/** Headline shown over the lower portion of the slide. */
title: string;
/** Label for the pill call-to-action button. */
buttonLabel: string;
/** Photo source. Defaults to a themed gradient placeholder when omitted. */
imageSrc?: string;
}
export interface CarouselProps {
/** Slide items. Defaults to 4 generic demo slides. */
slides?: CarouselSlideItem[];
/** Initially active slide index. Defaults to `0`. */
activeIndex?: number;
/** Called with the slide index whenever a slide's CTA button is clicked. */
onSlideClick?: (index: number) => void;
/** Accessible label for the "previous" control. Defaults to `"Previous slide"`. */
previousLabel?: string;
/** Accessible label for the "next" control. Defaults to `"Next slide"`. */
nextLabel?: string;
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
const PLACEHOLDER_PALETTE: readonly [string, string][] = [
["#1e293b", "#38bdf8"],
["#312e81", "#f472b6"],
["#164e63", "#facc15"],
["#3f3f46", "#a3e635"],
];
const DEFAULT_SLIDES: CarouselSlideItem[] = [
{ title: "Northern Lights Expedition", buttonLabel: "Explore trip" },
{ title: "Kyoto in Autumn", buttonLabel: "View gallery" },
{ title: "Sahara Dune Crossing", buttonLabel: "Book now" },
{ title: "Fjords of Norway", buttonLabel: "Learn more" },
];
function placeholderSlideImage(index: number): string {
const [top, accent] = PLACEHOLDER_PALETTE[index % PLACEHOLDER_PALETTE.length]!;
const markup =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400">' +
`<rect width="640" height="400" fill="${top}"/>` +
`<circle cx="500" cy="110" r="70" fill="${accent}"/>` +
'<path d="M0 300 L160 180 L280 260 L420 140 L640 280 L640 400 L0 400 Z" fill="rgba(255,255,255,0.1)"/>' +
"</svg>";
return `data:image/svg+xml,${encodeURIComponent(markup)}`;
}
let carouselInstanceCounter = 0;
/**
* A horizontal carousel of large rounded photo slides with a headline + CTA
* overlay, navigated via round previous/next controls. Call with no
* arguments for a working demo with 4 generic placeholder slides.
*/
function carousel(props: CarouselProps = {}): DomphyElement<"div"> {
const instanceId = ++carouselInstanceCounter;
const slides = props.slides && props.slides.length > 0 ? props.slides : DEFAULT_SLIDES;
const totalSlides = slides.length;
const previousLabel = props.previousLabel ?? "Previous slide";
const nextLabel = props.nextLabel ?? "Next slide";
const activeIndexState = toState(
Math.min(Math.max(props.activeIndex ?? 0, 0), totalSlides - 1),
`carousel-active-${instanceId}`,
);
const goToIndex = (nextIndex: number) => {
const wrapped = ((nextIndex % totalSlides) + totalSlides) % totalSlides;
activeIndexState.set(wrapped);
};
const slideElements: DomphyElement<"div">[] = slides.map((slide, index) => {
const imageSource = slide.imageSrc ?? placeholderSlideImage(index);
const computeTransform = (listener: Listener): string => {
const distance = index - activeIndexState.get(listener);
const translatePercent = distance * 62;
const scale = distance === 0 ? 1 : 0.86;
return `translateX(${translatePercent}%) scale(${scale})`;
};
const computeOpacity = (listener: Listener): number => {
const distance = Math.abs(index - activeIndexState.get(listener));
if (distance === 0) return 1;
if (distance === 1) return 0.55;
return 0;
};
const computeZIndex = (listener: Listener): number => {
const distance = Math.abs(index - activeIndexState.get(listener));
return totalSlides - distance;
};
const computePointerEvents = (listener: Listener): string =>
Math.abs(index - activeIndexState.get(listener)) <= 1 ? "auto" : "none";
return {
div: [
{
img: null,
src: imageSource,
alt: slide.title,
style: { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block" },
} as DomphyElement<"img">,
{
div: [
{ h3: slide.title, $: [heading({ color: "neutral" })] } as DomphyElement,
{
button: [
{ span: slide.buttonLabel },
{ span: "→", ariaHidden: "true", "data-carousel-cta-icon": "true", style: { display: "inline-block", transform: "translateX(0)" } },
],
onClick: (event: Event) => {
event.stopPropagation();
props.onSlideClick?.(index);
},
style: {
display: "inline-flex",
alignItems: "center",
gap: themeSpacing(2),
marginTop: themeSpacing(3),
paddingBlock: themeSpacing(2),
paddingInline: themeSpacing(5),
borderRadius: themeSpacing(10),
border: "none",
cursor: "pointer",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", "primary"),
color: (listener: Listener) => themeColor(listener, "shift-9", "primary"),
"&:hover [data-carousel-cta-icon]": {
transform: "translateX(0.35em)",
transition: "transform 150ms ease-out",
},
},
} as DomphyElement<"button">,
],
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
insetBlockEnd: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
padding: themeSpacing(6),
backgroundImage: (listener: Listener) =>
`linear-gradient(to top, ${themeColor(listener, "inherit")} 5%, transparent 75%)`,
},
} as DomphyElement<"div">,
],
_key: `carousel-slide-${instanceId}-${index}`,
role: "group",
ariaRoledescription: "slide",
ariaLabel: `${index + 1} of ${totalSlides}`,
onClick: () => goToIndex(index),
dataTone: "shift-16",
style: {
position: "absolute",
inset: 0,
overflow: "hidden",
borderRadius: themeSpacing(6),
cursor: "pointer",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
transition: "transform 400ms cubic-bezier(0.22, 1, 0.36, 1), opacity 400ms ease",
transform: computeTransform,
opacity: computeOpacity,
zIndex: computeZIndex,
pointerEvents: computePointerEvents,
} as StyleObject,
} as DomphyElement<"div">;
});
const controlButtonStyle = (): StyleObject => ({
borderRadius: "50%",
width: themeSpacing(11),
height: themeSpacing(11),
padding: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
// Function form (not a static themeSpacing() string) so this reads as a
// theme-driven size, not a hardcoded typography literal. `color` is
// repeated here (buttonGhost() already sets it) only because doctor's
// `missing-color` check inspects the authored native style, not what a
// `$` patch will merge in at render time.
fontSize: (listener: Listener) => themeSize(listener, "increase-1"),
color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
});
const previousControl: DomphyElement<"button"> = {
button: "‹",
ariaLabel: previousLabel,
$: [buttonGhost({ color: "neutral" })],
onClick: () => goToIndex(activeIndexState.get() - 1),
style: controlButtonStyle(),
} as DomphyElement<"button">;
const nextControl: DomphyElement<"button"> = {
button: "›",
ariaLabel: nextLabel,
$: [buttonGhost({ color: "neutral" })],
onClick: () => goToIndex(activeIndexState.get() + 1),
style: controlButtonStyle(),
} as DomphyElement<"button">;
const track: DomphyElement<"div"> = {
div: slideElements,
style: {
position: "relative",
width: "100%",
aspectRatio: "16 / 9",
overflow: "hidden",
} as StyleObject,
} as DomphyElement<"div">;
const controls: DomphyElement<"div"> = {
div: [previousControl, nextControl],
style: {
display: "flex",
justifyContent: "center",
gap: themeSpacing(4),
marginTop: themeSpacing(4),
} as StyleObject,
} as DomphyElement<"div">;
return {
div: [track, controls],
style: {
width: "100%",
maxWidth: themeSpacing(200),
marginInline: "auto",
...(props.style ?? {}),
} as StyleObject,
} as DomphyElement<"div">;
}
export { carousel };