posterReveal
A Labs block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call posterReveal() with no arguments for a working demo, or edit the code below live.
Implementation notes
Fully data-driven (ordered layers array): per-layer motion() cascade (scale/x/y/opacity, configurable delay/duration/easing) into a CSS-grid poster, then a group-level camera-zoom motion() beat delayed until the cascade finishes, plus a working replay control (bumps a version counter folded into each layer's _key to force remount/replay). Spring physics is approximated via a cubic-bezier overshoot easing default (Domphy has no bundled spring integrator, same tradeoff as dock.ts/cardStack.ts elsewhere in this package) rather than true mass/stiffness/damping simulation. Per instructions, the reference demo's actual GTA VI box art is NOT reproduced -- ships with generic themed gradient placeholder panels + a generic wordmark layer. The reference page's secondary 'view code' corner control is omitted -- it's Aceternity's own sandbox chrome, not listed in the component's own props contract.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "GTA VI Poster" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A hero
// intro that assembles a grid of poster panels with a staggered zoom-in
// cascade, finished by a slow group-level camera zoom, with a control to
// replay the whole sequence.
//
// The reference demo's own content happens to be box art for a specific
// unreleased game (per the spec's research note) — that artwork is not
// reproduced here. This component is data-driven (an ordered `layers` array)
// and ships with generic themed placeholder panels instead.
//
// Replay is implemented the same way textAnimate.ts replays on text change:
// there is no "rewind this WAAPI animation" primitive, so a version counter
// is folded into every layer's `_key`. Bumping the counter makes the whole
// poster subtree a *new* set of DOM nodes on the next render, which tears
// down the old ones and re-mounts fresh ones — and since `motion()`'s
// initial -> animate tween runs from `_onMount`, that remount is exactly
// "replay the timeline from the start" for free.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, motion } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface PosterLayer {
id: string;
/** Panel label — shown as decorative micro-text on placeholder panels, and read by screen readers via `ariaLabel`. */
label: string;
/** Photo/artwork source. Defaults to a themed gradient placeholder when omitted. */
imageSrc?: string;
/** Placeholder gradient family, used only when `imageSrc` is omitted. */
colorFamily?: ThemeColor;
/** `"wordmark"` layers render bold centered text over the grid instead of a media panel — still positioned via `gridColumn`/`gridRow` like any other layer (typically `"1 / -1"` to span the whole poster). Defaults to `"panel"`. */
kind?: "panel" | "wordmark";
/** CSS grid placement within the poster's 3x3 grid, e.g. `"1 / 2"`. */
gridColumn: string;
gridRow: string;
/** Exaggerated starting scale this layer zooms in from. Defaults to `1.55`. */
initialScale?: number;
/** Exaggerated starting offset (px) this layer settles in from. Both default to `0`. */
initialOffsetX?: number;
initialOffsetY?: number;
/** Reveal delay, in ms. Defaults to `index * 130`. */
delay?: number;
/** Reveal duration, in ms. Defaults to `props.revealDuration`. */
duration?: number;
/** Per-layer easing override. Defaults to `props.revealEasing`. */
easing?: string;
}
export interface PosterRevealProps {
layers?: PosterLayer[];
/** Default reveal duration for layers that don't set their own, in ms. Defaults to `700`. */
revealDuration?: number;
/** Default reveal easing for layers that don't set their own — a springy overshoot-then-settle
* curve (Domphy has no bundled spring integrator; see `dock.ts`/`cardStack.ts` for the same
* cubic-bezier approximation elsewhere in this package). Defaults to `"cubic-bezier(0.34, 1.56, 0.64, 1)"`. */
revealEasing?: string;
/** Group-level "camera zoom" scale played once every layer has settled. Defaults to `1.035`. */
cameraZoomScale?: number;
/** Camera zoom duration, in ms. Defaults to `1300`. */
cameraZoomDuration?: number;
/** Poster frame aspect ratio. Defaults to `"3 / 4"` (portrait). */
aspectRatio?: string;
/** Poster frame max width, in `themeSpacing` units. Defaults to `100`. */
maxWidthUnits?: number;
showReplayControl?: boolean;
/** Fired once, after the last layer has settled and the camera zoom has finished. */
onSequenceComplete?: () => void;
style?: StyleObject;
}
const PLACEHOLDER_COLORS: ThemeColor[] = ["primary", "secondary", "info", "success", "attention", "highlight"];
function defaultPanelLayers(): PosterLayer[] {
const cells: PosterLayer[] = [];
for (let row = 1; row <= 3; row++) {
for (let column = 1; column <= 3; column++) {
const index = cells.length;
cells.push({
id: `panel-${index}`,
label: `Panel ${index + 1}`,
colorFamily: PLACEHOLDER_COLORS[index % PLACEHOLDER_COLORS.length],
gridColumn: `${column} / ${column + 1}`,
gridRow: `${row} / ${row + 1}`,
initialScale: 1.5 + (index % 3) * 0.08,
delay: index * 130,
});
}
}
return cells;
}
function defaultLayers(): PosterLayer[] {
const panels = defaultPanelLayers();
const wordmark: PosterLayer = {
id: "wordmark",
label: "Wordmark",
kind: "wordmark",
gridColumn: "1 / -1",
gridRow: "1 / -1",
initialScale: 1.3,
delay: panels.length * 130,
duration: 620,
};
return [...panels, wordmark];
}
function refreshGlyph(): DomphyElement<"span"> {
return {
span: [
{
svg: [
{
path: null,
d: "M4 12a8 8 0 0 1 13.2-6.1L20 8.3",
fill: "none",
} as DomphyElement<"path">,
{ polygon: null, points: "20,3.6 20,9.3 14.3,7.7" } as DomphyElement<"polygon">,
{
path: null,
d: "M20 12a8 8 0 0 1-13.2 6.1L4 15.7",
fill: "none",
} as DomphyElement<"path">,
{ polygon: null, points: "4,20.4 4,14.7 9.7,16.3" } as DomphyElement<"polygon">,
],
viewBox: "0 0 24 24",
fill: "currentColor",
stroke: "currentColor",
strokeWidth: "1.6",
strokeLinecap: "round",
strokeLinejoin: "round",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">,
],
ariaHidden: "true",
style: { display: "inline-flex", width: themeSpacing(4.5), height: themeSpacing(4.5) },
};
}
function panelMedia(layer: PosterLayer, index: number): DomphyElement<"div"> {
if (layer.imageSrc) {
return {
div: null,
style: {
position: "absolute",
inset: 0,
backgroundImage: `url(${layer.imageSrc})`,
backgroundSize: "cover",
backgroundPosition: "center",
},
} as DomphyElement<"div">;
}
const family = layer.colorFamily ?? PLACEHOLDER_COLORS[index % PLACEHOLDER_COLORS.length];
return {
div: null,
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
backgroundImage: (listener: Listener) =>
`linear-gradient(155deg, ${themeColor(listener, "shift-8", family)}, ${themeColor(listener, "shift-15", family)})`,
},
} as DomphyElement<"div">;
}
function wordmarkContent(text: string): DomphyElement<"div"> {
return {
div: [
{
h1: text,
$: [heading({ color: "neutral" })],
style: {
margin: 0,
textAlign: "center",
textTransform: "uppercase",
color: (listener: Listener) => themeColor(listener, "shift-11", "neutral"),
} as StyleObject,
} as DomphyElement<"h1">,
],
ariaHidden: "true",
// Decorative scrim with no text of its own (the h1 inside sets its own color via
// heading()) — exempt from the missing-color contract, matching focusCards.ts's
// bottom-title scrim.
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: themeSpacing(4),
backgroundImage: (listener: Listener) =>
`radial-gradient(ellipse at center, ${themeColor(listener, "shift-17", "neutral")} 0%, transparent 70%)`,
} as StyleObject,
} as DomphyElement<"div">;
}
function buildLayer(
layer: PosterLayer,
index: number,
version: number,
defaultDuration: number,
defaultEasing: string,
): DomphyElement<"div"> {
const delay = layer.delay ?? index * 130;
const duration = layer.duration ?? defaultDuration;
const easing = layer.easing ?? defaultEasing;
const initialScale = layer.initialScale ?? 1.55;
const isWordmark = layer.kind === "wordmark";
return {
div: [isWordmark ? wordmarkContent(layer.label) : panelMedia(layer, index)],
_key: `${layer.id}-${version}`,
ariaLabel: layer.label,
// The layer wrapper's own `outline` is a decorative panel-seam border, not text —
// the panel/wordmark content inside sets its own color independently.
_doctorDisable: "missing-color",
$: [
motion({
initial: {
scale: initialScale,
x: layer.initialOffsetX ?? 0,
y: layer.initialOffsetY ?? 0,
opacity: isWordmark ? 0 : 1,
},
animate: { scale: 1, x: 0, y: 0, opacity: 1 },
transition: { duration, delay, easing },
}),
],
style: {
position: "relative",
gridColumn: layer.gridColumn,
gridRow: layer.gridRow,
overflow: isWordmark ? "visible" : "hidden",
zIndex: isWordmark ? 2 : 1,
outline: isWordmark ? "none" : (listener: Listener) => `1px solid ${themeColor(listener, "shift-4", "neutral")}`,
outlineOffset: "-1px",
} as StyleObject,
} as DomphyElement<"div">;
}
/**
* A hero intro that assembles a grid of poster panels with a staggered
* zoom-in cascade, finished by a slow group-level camera zoom. Call with no
* arguments for a working demo — a 3x3 grid of themed placeholder panels plus
* a centered wordmark, with a working replay control. Data-driven via
* `props.layers`; the reference demo's specific box-art content is not
* reproduced.
*/
function posterReveal(props: PosterRevealProps = {}): DomphyElement<"div"> {
const layers = props.layers && props.layers.length > 0 ? props.layers : defaultLayers();
const defaultDuration = props.revealDuration ?? 700;
const defaultEasing = props.revealEasing ?? "cubic-bezier(0.34, 1.56, 0.64, 1)";
const cameraZoomScale = props.cameraZoomScale ?? 1.035;
const cameraZoomDuration = props.cameraZoomDuration ?? 1300;
const aspectRatio = props.aspectRatio ?? "3 / 4";
const maxWidthUnits = props.maxWidthUnits ?? 100;
const showReplayControl = props.showReplayControl ?? true;
const replayVersion = toState(0);
const totalCascadeMs = layers.reduce((max, layer, index) => {
const delay = layer.delay ?? index * 130;
const duration = layer.duration ?? defaultDuration;
return Math.max(max, delay + duration);
}, 0);
let sequenceTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleCompletion = () => {
if (sequenceTimer) clearTimeout(sequenceTimer);
if (!props.onSequenceComplete) return;
sequenceTimer = setTimeout(props.onSequenceComplete, totalCascadeMs + cameraZoomDuration);
};
function buildPoster(version: number): DomphyElement<"div"> {
return {
div: layers.map((layer, index) => buildLayer(layer, index, version, defaultDuration, defaultEasing)),
_key: `poster-frame-${version}`,
$: [
motion({
animate: { scale: cameraZoomScale },
transition: { duration: cameraZoomDuration, delay: totalCascadeMs, easing: "ease-out" },
}),
],
style: {
position: "absolute",
inset: 0,
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gridTemplateRows: "repeat(3, 1fr)",
} as StyleObject,
};
}
const replayButton: DomphyElement<"button"> | null = showReplayControl
? ({
button: [refreshGlyph()],
type: "button",
ariaLabel: "Replay poster reveal",
onClick: () => replayVersion.set(replayVersion.get() + 1),
dataTone: "shift-0",
style: {
position: "absolute",
insetBlockStart: themeSpacing(3),
insetInlineStart: themeSpacing(3),
zIndex: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: themeSpacing(9),
height: themeSpacing(9),
border: "none",
borderRadius: "50%",
cursor: "pointer",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
boxShadow: (listener: Listener) => `0 ${themeSpacing(1)} ${themeSpacing(4)} ${themeColor(listener, "shift-4", "neutral")}`,
},
} as DomphyElement<"button">)
: null;
return {
div: [
{ div: (listener: Listener) => [buildPoster(replayVersion.get(listener))], style: { position: "relative", width: "100%", height: "100%" } } as DomphyElement<"div">,
...(replayButton ? [replayButton] : []),
],
ariaLabel: "Poster reveal",
dataTone: "shift-17",
_onMount: (node: ElementNode) => {
scheduleCompletion();
const release = replayVersion.addListener(() => scheduleCompletion());
node.addHook("Remove", () => {
release();
if (sequenceTimer) clearTimeout(sequenceTimer);
});
},
style: {
position: "relative",
width: "100%",
maxWidth: themeSpacing(maxWidthUnits),
aspectRatio,
overflow: "hidden",
borderRadius: themeSpacing(4),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { posterReveal };