marquee3D
A Effects 3D block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call marquee3D() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full behavior implemented, pure CSS (no animation-frame loop, per the spec's own animation note): a fixed perspective()+rotateX/rotateY/rotateZ tilt on the grid wrapper, per-column infinite loops using the same 'translate by exactly one repeated group length' trick this package's marquee.ts uses, alternating columns via animation-direction plus a small negative animation-delay stagger, an optional co-tilted grid-line decoration layer (a plain descendant of the transformed wrapper, so it tilts for free with no extra transform needed), and an optional un-rotated hero heading overlay. The documented upstream grid-line defaults (200px horizontal / 150px vertical) are preserved as the prop defaults but converted to themeSpacing() units internally so the component never emits literal px in its style objects. Exact tilt angles (rotateX 55°/rotateZ -45°) are this implementer's reasonable choice for a classic isometric read, since the spec didn't mandate specific degrees.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "3D Marquee" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// hero-section grid of images split into several vertical columns that
// scroll infinitely, tilted into an isometric 3D perspective, with
// alternating columns drifting in opposite directions.
//
// Pure CSS, no animation-frame loop: a single fixed `perspective()` +
// `rotateX/rotateY/rotateZ` transform tilts the whole grid wrapper once
// (applied inline, never re-computed), and each column independently loops
// its own repeated image stack via the same "translate the track by exactly
// one repeated group's length" trick this package's own `marquee.ts` uses
// for its horizontal/vertical strip — since every repeated group is
// identical, the loop point is imperceptible. Alternating columns just flip
// `animation-direction` to `reverse` (rather than negating the keyframe
// itself) and get a small negative `animation-delay` offset so they don't
// all start their loop in lockstep.
//
// The optional overlay grid lines are a co-planar decorative child *inside*
// the tilted wrapper (not a separate un-rotated layer), so they visually
// tilt along with the image plane for free — a plain descendant of a
// transformed element renders inside that element's transformed coordinate
// space, no `transform-style: preserve-3d`/per-child transform needed. The
// optional hero heading overlay is the opposite: a sibling of the tilted
// wrapper, deliberately left un-rotated so its text stays flat and legible
// on top of the tilted grid.
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";
export interface Marquee3DImage {
src: string;
alt?: string;
}
export interface Marquee3DProps {
/** Pool of images distributed round-robin across the columns. Defaults to 12 generated placeholders. */
images?: Array<string | Marquee3DImage>;
/** Number of vertical columns. Defaults to `4`. */
columns?: number;
/** Overlay content rendered flat (un-rotated) above the tilted grid — the hero-banner heading variant. Pass `null` to omit it entirely. Defaults to a short demo headline. */
overlay?: DomphyElement | DomphyElement[] | null;
/** Shows the faint overlay grid-line decoration co-tilted with the image plane. Defaults to `true`. */
showGridLines?: boolean;
/** Horizontal spacing between grid lines, in px (matches the reference component's own documented default). Defaults to `200`. */
lineOffsetX?: number;
/** Vertical spacing between grid lines, in px (matches the reference component's own documented default). Defaults to `150`. */
lineOffsetY?: number;
/** Seconds per full column loop. Defaults to `36`. */
duration?: number;
/** Gap between stacked tiles within a column, in `themeSpacing` units. Defaults to `3`. */
gap?: number;
/** Each tile's rendered height, in `themeSpacing` units. Defaults to `56`. */
tileHeight?: number;
/** Overall grid area height, in `themeSpacing` units. Defaults to `140`. */
areaHeight?: number;
/** Tilt rotation around the X axis, in deg. Defaults to `55`. */
rotateXDegrees?: number;
/** Tilt rotation around the Y axis, in deg. Defaults to `0`. */
rotateYDegrees?: number;
/** Tilt rotation around the Z axis, in deg. Defaults to `-45`. */
rotateZDegrees?: number;
/** CSS `perspective()` distance, in px. Defaults to `1400`. */
perspectiveDistance?: number;
/** Extra class name merged onto the outer perspective container's native `class` attribute. */
className?: string;
/** Extra class name merged onto every image tile's native `class` attribute. */
imageClassName?: string;
/** Passthrough style merged onto the outer perspective container. */
style?: StyleObject;
}
const DEFAULT_IMAGE_COUNT = 12;
// Repeating each column's image set this many times guarantees the track is
// always taller than its own single-group height, so the seamless loop
// (translate by exactly one group) never reveals a gap.
const COLUMN_REPEAT_COUNT = 3;
let marquee3DInstanceCounter = 0;
function buildDefaultImages(): Marquee3DImage[] {
return Array.from({ length: DEFAULT_IMAGE_COUNT }, (_unused, index) => ({
src: `https://picsum.photos/seed/domphy-marquee3d-${index + 1}/480/600`,
alt: "",
}));
}
function defaultOverlay(): DomphyElement[] {
return [
{ h1: "A wall of work, tilted into view", $: [heading()] } as DomphyElement,
{
p: "Every column loops on its own, drifting past in alternating directions.",
$: [paragraph()],
} as DomphyElement,
];
}
/** Splits `items` round-robin across `columnCount` buckets so each column gets a varied mix. */
function distributeRoundRobin<T>(items: T[], columnCount: number): T[][] {
const buckets: T[][] = Array.from({ length: columnCount }, () => []);
items.forEach((item, index) => buckets[index % columnCount].push(item));
return buckets;
}
function columnTrack(
images: Marquee3DImage[],
columnIndex: number,
duration: number,
gapUnits: number,
tileHeightUnits: number,
imageClassName: string | undefined,
instanceId: number,
): DomphyElement<"div"> {
const reverse = columnIndex % 2 === 1;
const stack: DomphyElement[] = [];
for (let repeat = 0; repeat < COLUMN_REPEAT_COUNT; repeat++) {
images.forEach((image, imageIndex) => {
stack.push({
img: null,
src: image.src,
alt: image.alt ?? "",
loading: "lazy",
_key: `marquee3d-tile-${columnIndex}-${repeat}-${imageIndex}`,
// Duplicate repeats after the first exist purely for the seamless
// loop — screen readers should only announce each image once.
ariaHidden: repeat === 0 ? undefined : "true",
_doctorDisable: "missing-color",
class: imageClassName,
style: {
display: "block",
width: "100%",
height: themeSpacing(tileHeightUnits),
objectFit: "cover",
borderRadius: themeSpacing(2),
flexShrink: 0,
} as StyleObject,
} as DomphyElement);
});
}
const keyframes = {
from: { transform: "translateY(0)" },
to: { transform: `translateY(calc(-100% / ${COLUMN_REPEAT_COUNT}))` },
};
const animationName = `marquee3d-col-${instanceId}-${hashString(JSON.stringify({ columnIndex, duration }))}`;
// Negative delays start each column mid-loop instead of at the same
// shared origin, so neighboring columns never visually sync up.
const delaySeconds = -((columnIndex * duration) / (COLUMN_REPEAT_COUNT * 2));
return {
div: [
{
div: stack,
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(gapUnits),
animation: `${animationName} ${duration}s linear infinite ${reverse ? "reverse" : "normal"}`,
animationDelay: `${delaySeconds.toFixed(2)}s`,
[`@keyframes ${animationName}`]: keyframes,
} as StyleObject,
} as DomphyElement,
],
_key: `marquee3d-column-${columnIndex}`,
style: {
flex: "1 1 0",
overflow: "hidden",
} as StyleObject,
};
}
/** Faint overlay grid lines, co-planar with (and thus tilted along with) the image columns. */
function gridLinesLayer(lineOffsetXUnits: number, lineOffsetYUnits: number): DomphyElement<"div"> {
return {
div: null,
ariaHidden: "true",
// Decorative line overlay with no text of its own — exempt from the
// missing-color contract, matching this package's other purely
// decorative background layers (e.g. `heroHighlight.ts`'s dot grid).
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: themeSpacing(-16),
pointerEvents: "none",
backgroundImage: (listener: Listener) =>
`linear-gradient(to right, ${themeColor(listener, "shift-4")} 1px, transparent 1px), ` +
`linear-gradient(to bottom, ${themeColor(listener, "shift-4")} 1px, transparent 1px)`,
backgroundSize: `${themeSpacing(lineOffsetXUnits)} ${themeSpacing(lineOffsetYUnits)}`,
} as StyleObject,
} as DomphyElement<"div">;
}
/**
* A hero-section grid of images split into several vertical columns that
* scroll infinitely and continuously, tilted into an isometric 3D
* perspective. Call with no arguments for a working demo — 4 columns of
* generated placeholder images looping under a hero headline overlay.
*/
function marquee3D(props: Marquee3DProps = {}): DomphyElement<"div"> {
const instanceId = ++marquee3DInstanceCounter;
const sourceImages: Marquee3DImage[] =
props.images && props.images.length > 0
? props.images.map((image) => (typeof image === "string" ? { src: image } : image))
: buildDefaultImages();
const columnCount = Math.max(2, Math.round(props.columns ?? 4));
const overlayContent =
props.overlay === null ? [] : (props.overlay ?? defaultOverlay());
const overlayChildren = Array.isArray(overlayContent) ? overlayContent : [overlayContent];
const showGridLines = props.showGridLines ?? true;
// Documented upstream defaults (200px / 150px) — converted to
// `themeSpacing` units (spacing unit N ≈ 4px at the base font size) so the
// grid still scales with theme density rather than staying pixel-locked.
const lineOffsetXUnits = (props.lineOffsetX ?? 200) / 4;
const lineOffsetYUnits = (props.lineOffsetY ?? 150) / 4;
const duration = Math.max(6, props.duration ?? 36);
const gapUnits = props.gap ?? 3;
const tileHeightUnits = props.tileHeight ?? 56;
const areaHeightUnits = props.areaHeight ?? 140;
const rotateXDegrees = props.rotateXDegrees ?? 55;
const rotateYDegrees = props.rotateYDegrees ?? 0;
const rotateZDegrees = props.rotateZDegrees ?? -45;
const perspectiveDistance = props.perspectiveDistance ?? 1400;
const columnBuckets = distributeRoundRobin(sourceImages, columnCount);
const tiltedGrid: DomphyElement<"div"> = {
div: [
...columnBuckets.map((bucket, columnIndex) =>
columnTrack(bucket, columnIndex, duration, gapUnits, tileHeightUnits, props.imageClassName, instanceId),
),
...(showGridLines ? [gridLinesLayer(lineOffsetXUnits, lineOffsetYUnits)] : []),
],
style: {
position: "relative",
display: "flex",
gap: themeSpacing(gapUnits),
width: "100%",
height: "100%",
transform: `rotateX(${rotateXDegrees}deg) rotateY(${rotateYDegrees}deg) rotateZ(${rotateZDegrees}deg)`,
transformOrigin: "50% 50%",
} as StyleObject,
};
const overlayLayer: DomphyElement<"div"> | null =
overlayChildren.length > 0
? ({
div: overlayChildren,
style: {
position: "absolute",
inset: 0,
zIndex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: themeSpacing(3),
paddingInline: themeSpacing(8),
pointerEvents: "none",
background: (listener: Listener) =>
`radial-gradient(ellipse at center, color-mix(in srgb, ${themeColor(listener)} 65%, transparent), transparent 70%)`,
color: (listener: Listener) => themeColor(listener, "shift-9"),
} as StyleObject,
} as DomphyElement<"div">)
: null;
return {
div: [tiltedGrid, ...(overlayLayer ? [overlayLayer] : [])],
class: props.className,
dataTone: "shift-16",
style: {
position: "relative",
overflow: "hidden",
height: themeSpacing(areaHeightUnits),
borderRadius: themeSpacing(4),
perspective: `${perspectiveDistance}px`,
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { marquee3D };