Vertical Scene Gallery
A direction-aware gallery that reveals later scenes from below and earlier scenes from above with a paired vertical image transition.
marketingvertical-reveal
Vertical studies
Scene 1of 4
Red rock country · Late afternoon
The road narrows into red stone.
A long route becomes intimate when the horizon folds into canyon walls and every bend changes the scale of the landscape.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
SlidingList,
type SlidingListItem,
} from "@/components/ui/sliding-list";
import { cn } from "@/lib/utils";
import "./vertical-scene.css";
export type VerticalSceneImage = {
src: string;
alt: string;
};
export type VerticalSceneItem = {
/** Stable value used by controlled state and callbacks. */
value: string;
/** Short label rendered in the scene selector. */
label: string;
/** Main heading displayed over the active scene. */
title: string;
/** Optional supporting copy below the heading. */
description?: string;
/** Optional context displayed above the heading. */
context?: string;
image: VerticalSceneImage;
imagePosition?: React.CSSProperties["objectPosition"];
};
export interface VerticalSceneProps
extends Omit<React.ComponentProps<"section">, "onChange"> {
items: readonly VerticalSceneItem[];
/** Controlled selected scene value. */
value?: string;
/** Initial selected scene value for uncontrolled usage. */
defaultValue?: string;
/** Called when a scene is selected. */
onValueChange?: (value: string, item: VerticalSceneItem) => void;
/** Accessible label for the scene selector. */
selectorLabel?: string;
/** Persistent label displayed in the stage header. */
stageLabel?: string;
/** Message displayed when no scenes are provided. */
emptyLabel?: string;
};
type SceneDirection = -1 | 0 | 1;
type SceneTransitionState = {
value: string;
index: number;
direction: Exclude<SceneDirection, 0>;
};
const MEDIA_EASE = [0.77, 0, 0.18, 1] as const;
const CONTENT_EASE = [0.22, 1, 0.36, 1] as const;
const MEDIA_DURATION = 0.56;
const CONTENT_ENTER_DURATION = 0.5;
const CONTENT_ENTER_DELAY = 0.12;
const CONTENT_EXIT_DURATION = 0.22;
const mediaLayerVariants = {
enter: (direction: SceneDirection) => ({
transform: `translate3d(0, ${direction * 100}%, 0)`,
}),
center: { transform: "translate3d(0, 0%, 0)" },
exit: (direction: SceneDirection) => ({
transform: `translate3d(0, ${direction * -5}%, 0)`,
}),
};
const mediaImageVariants = {
enter: (direction: SceneDirection) => ({
transform:
direction === 0
? "translate3d(0, 0%, 0) scale(1)"
: `translate3d(0, ${direction * -100}%, 0) scale(1.04)`,
}),
center: { transform: "translate3d(0, 0%, 0) scale(1)" },
exit: { transform: "translate3d(0, 0%, 0) scale(1.02)" },
};
const contentVariants = {
enter: (direction: SceneDirection) => ({
opacity: direction === 0 ? 1 : 0,
transform: `translate3d(0, ${direction * 28}px, 0)`,
}),
// The outgoing copy clears quickly while the incoming copy waits a beat,
// then glides in so it settles together with the media pan.
center: (direction: SceneDirection) => ({
opacity: 1,
transform: "translate3d(0, 0px, 0)",
transition:
direction === 0
? { duration: 0 }
: {
duration: CONTENT_ENTER_DURATION,
ease: CONTENT_EASE,
delay: CONTENT_ENTER_DELAY,
},
}),
exit: (direction: SceneDirection) => ({
opacity: 0,
transform: `translate3d(0, ${direction * -14}px, 0)`,
transition:
direction === 0
? { duration: 0 }
: { duration: CONTENT_EXIT_DURATION, ease: CONTENT_EASE },
}),
};
export function VerticalScene({
items,
value,
defaultValue,
onValueChange,
selectorLabel = "Choose a scene",
stageLabel = "Scene collection",
emptyLabel = "No scenes available",
className,
...props
}: VerticalSceneProps) {
const reactId = React.useId();
const panelId = `${reactId}-panel`;
const shouldReduceMotion = useReducedMotion() === true;
const [internalValue, setInternalValue] = React.useState(
defaultValue ?? items[0]?.value ?? "",
);
const controlled = value !== undefined;
const selectedValue = controlled ? value : internalValue;
const selectedIndex = Math.max(
0,
items.findIndex((item) => item.value === selectedValue),
);
const selectedItem = items[selectedIndex];
const selectorItems = React.useMemo<SlidingListItem[]>(
() =>
items.map((item, index) => ({
value: item.value,
label: item.label,
id: `${reactId}-tab-${index}`,
ariaControls: panelId,
})),
[items, panelId, reactId],
);
const [transitionState, setTransitionState] =
React.useState<SceneTransitionState>(() => ({
value: selectedValue,
index: selectedIndex,
direction: 1,
}));
let direction = transitionState.direction;
if (
transitionState.value !== selectedValue ||
transitionState.index !== selectedIndex
) {
direction = selectedIndex < transitionState.index ? -1 : 1;
setTransitionState({
value: selectedValue,
index: selectedIndex,
direction,
});
}
const motionDirection: SceneDirection = shouldReduceMotion ? 0 : direction;
const selectValue = React.useCallback(
(nextValue: string) => {
const nextItem = items.find((item) => item.value === nextValue);
if (!nextItem) return;
if (!controlled) {
setInternalValue(nextItem.value);
}
if (nextItem.value !== selectedItem?.value) {
onValueChange?.(nextItem.value, nextItem);
}
},
[controlled, items, onValueChange, selectedItem?.value],
);
const preloadImage = React.useCallback((src: string) => {
if (typeof window === "undefined") return;
const image = new window.Image();
image.src = src;
}, []);
const preloadScene = React.useCallback(
(item: SlidingListItem) => {
const scene = items.find((candidate) => candidate.value === item.value);
if (scene) {
preloadImage(scene.image.src);
}
},
[items, preloadImage],
);
if (!selectedItem) {
return (
<section
data-slot="vertical-scene"
className={cn("vertical-scene vertical-scene--empty", className)}
{...props}
>
<p>{emptyLabel}</p>
</section>
);
}
const activeTabId = `${reactId}-tab-${selectedIndex}`;
const mediaTransition = {
duration: shouldReduceMotion ? 0 : MEDIA_DURATION,
ease: MEDIA_EASE,
};
return (
<section
data-slot="vertical-scene"
data-direction={direction === 1 ? "down" : "up"}
data-motion={shouldReduceMotion ? "reduced" : "full"}
className={cn("vertical-scene", className)}
{...props}
>
<div
className="vertical-scene__media"
role="img"
aria-label={selectedItem.image.alt}
>
<AnimatePresence
initial={false}
mode="sync"
custom={motionDirection}
>
<motion.div
key={selectedItem.value}
className="vertical-scene__media-layer"
custom={motionDirection}
variants={mediaLayerVariants}
initial="enter"
animate="center"
exit="exit"
transition={mediaTransition}
>
<motion.img
aria-hidden="true"
alt=""
src={selectedItem.image.src}
draggable={false}
className="vertical-scene__image"
style={{ objectPosition: selectedItem.imagePosition }}
custom={motionDirection}
variants={mediaImageVariants}
initial="enter"
animate="center"
exit="exit"
transition={mediaTransition}
/>
</motion.div>
</AnimatePresence>
</div>
<div aria-hidden="true" className="vertical-scene__scrim" />
<div className="vertical-scene__chrome">
<header className="vertical-scene__header">
<p>{stageLabel}</p>
<p aria-live="polite" aria-atomic="true">
<span className="sr-only">Scene </span>
{selectedIndex + 1}
<span aria-hidden="true"> of </span>
<span className="sr-only">of </span>
{items.length}
</p>
</header>
<div className="vertical-scene__body">
<SlidingList
items={selectorItems}
value={selectedValue}
onValueChange={selectValue}
onItemPointerEnter={preloadScene}
onItemFocus={preloadScene}
aria-label={selectorLabel}
className="vertical-scene__selector"
itemClassName="vertical-scene__tab"
indicatorClassName="vertical-scene__tab-indicator"
/>
<div
id={panelId}
role="tabpanel"
aria-labelledby={activeTabId}
className="vertical-scene__content"
>
<AnimatePresence
initial={false}
mode="popLayout"
custom={motionDirection}
>
<motion.div
key={selectedItem.value}
custom={motionDirection}
variants={contentVariants}
initial="enter"
animate="center"
exit="exit"
className="vertical-scene__content-inner"
>
{selectedItem.context ? (
<p className="vertical-scene__context">
{selectedItem.context}
</p>
) : null}
<h2>{selectedItem.title}</h2>
{selectedItem.description ? (
<p className="vertical-scene__description">
{selectedItem.description}
</p>
) : null}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}