{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vertical-scene",
  "title": "Vertical Scene Gallery",
  "description": "A direction-aware gallery that reveals later scenes from below and earlier scenes from above with a paired vertical image transition.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ericts.com/r/sliding-list.json"
  ],
  "files": [
    {
      "path": "registry/base/blocks/vertical-scene.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n  SlidingList,\n  type SlidingListItem,\n} from \"@/components/ui/sliding-list\";\nimport { cn } from \"@/lib/utils\";\n\nimport \"./vertical-scene.css\";\n\nexport type VerticalSceneImage = {\n  src: string;\n  alt: string;\n};\n\nexport type VerticalSceneItem = {\n  /** Stable value used by controlled state and callbacks. */\n  value: string;\n  /** Short label rendered in the scene selector. */\n  label: string;\n  /** Main heading displayed over the active scene. */\n  title: string;\n  /** Optional supporting copy below the heading. */\n  description?: string;\n  /** Optional context displayed above the heading. */\n  context?: string;\n  image: VerticalSceneImage;\n  imagePosition?: React.CSSProperties[\"objectPosition\"];\n};\n\nexport interface VerticalSceneProps\n  extends Omit<React.ComponentProps<\"section\">, \"onChange\"> {\n  items: readonly VerticalSceneItem[];\n  /** Controlled selected scene value. */\n  value?: string;\n  /** Initial selected scene value for uncontrolled usage. */\n  defaultValue?: string;\n  /** Called when a scene is selected. */\n  onValueChange?: (value: string, item: VerticalSceneItem) => void;\n  /** Accessible label for the scene selector. */\n  selectorLabel?: string;\n  /** Persistent label displayed in the stage header. */\n  stageLabel?: string;\n  /** Message displayed when no scenes are provided. */\n  emptyLabel?: string;\n};\n\ntype SceneDirection = -1 | 0 | 1;\n\ntype SceneTransitionState = {\n  value: string;\n  index: number;\n  direction: Exclude<SceneDirection, 0>;\n};\n\nconst MEDIA_EASE = [0.77, 0, 0.18, 1] as const;\nconst CONTENT_EASE = [0.22, 1, 0.36, 1] as const;\nconst MEDIA_DURATION = 0.56;\nconst CONTENT_ENTER_DURATION = 0.5;\nconst CONTENT_ENTER_DELAY = 0.12;\nconst CONTENT_EXIT_DURATION = 0.22;\n\nconst mediaLayerVariants = {\n  enter: (direction: SceneDirection) => ({\n    transform: `translate3d(0, ${direction * 100}%, 0)`,\n  }),\n  center: { transform: \"translate3d(0, 0%, 0)\" },\n  exit: (direction: SceneDirection) => ({\n    transform: `translate3d(0, ${direction * -5}%, 0)`,\n  }),\n};\n\nconst mediaImageVariants = {\n  enter: (direction: SceneDirection) => ({\n    transform:\n      direction === 0\n        ? \"translate3d(0, 0%, 0) scale(1)\"\n        : `translate3d(0, ${direction * -100}%, 0) scale(1.04)`,\n  }),\n  center: { transform: \"translate3d(0, 0%, 0) scale(1)\" },\n  exit: { transform: \"translate3d(0, 0%, 0) scale(1.02)\" },\n};\n\nconst contentVariants = {\n  enter: (direction: SceneDirection) => ({\n    opacity: direction === 0 ? 1 : 0,\n    transform: `translate3d(0, ${direction * 28}px, 0)`,\n  }),\n  // The outgoing copy clears quickly while the incoming copy waits a beat,\n  // then glides in so it settles together with the media pan.\n  center: (direction: SceneDirection) => ({\n    opacity: 1,\n    transform: \"translate3d(0, 0px, 0)\",\n    transition:\n      direction === 0\n        ? { duration: 0 }\n        : {\n            duration: CONTENT_ENTER_DURATION,\n            ease: CONTENT_EASE,\n            delay: CONTENT_ENTER_DELAY,\n          },\n  }),\n  exit: (direction: SceneDirection) => ({\n    opacity: 0,\n    transform: `translate3d(0, ${direction * -14}px, 0)`,\n    transition:\n      direction === 0\n        ? { duration: 0 }\n        : { duration: CONTENT_EXIT_DURATION, ease: CONTENT_EASE },\n  }),\n};\n\nexport function VerticalScene({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  selectorLabel = \"Choose a scene\",\n  stageLabel = \"Scene collection\",\n  emptyLabel = \"No scenes available\",\n  className,\n  ...props\n}: VerticalSceneProps) {\n  const reactId = React.useId();\n  const panelId = `${reactId}-panel`;\n  const shouldReduceMotion = useReducedMotion() === true;\n  const [internalValue, setInternalValue] = React.useState(\n    defaultValue ?? items[0]?.value ?? \"\",\n  );\n  const controlled = value !== undefined;\n  const selectedValue = controlled ? value : internalValue;\n  const selectedIndex = Math.max(\n    0,\n    items.findIndex((item) => item.value === selectedValue),\n  );\n  const selectedItem = items[selectedIndex];\n  const selectorItems = React.useMemo<SlidingListItem[]>(\n    () =>\n      items.map((item, index) => ({\n        value: item.value,\n        label: item.label,\n        id: `${reactId}-tab-${index}`,\n        ariaControls: panelId,\n      })),\n    [items, panelId, reactId],\n  );\n  const [transitionState, setTransitionState] =\n    React.useState<SceneTransitionState>(() => ({\n      value: selectedValue,\n      index: selectedIndex,\n      direction: 1,\n    }));\n  let direction = transitionState.direction;\n\n  if (\n    transitionState.value !== selectedValue ||\n    transitionState.index !== selectedIndex\n  ) {\n    direction = selectedIndex < transitionState.index ? -1 : 1;\n    setTransitionState({\n      value: selectedValue,\n      index: selectedIndex,\n      direction,\n    });\n  }\n\n  const motionDirection: SceneDirection = shouldReduceMotion ? 0 : direction;\n\n  const selectValue = React.useCallback(\n    (nextValue: string) => {\n      const nextItem = items.find((item) => item.value === nextValue);\n\n      if (!nextItem) return;\n\n      if (!controlled) {\n        setInternalValue(nextItem.value);\n      }\n\n      if (nextItem.value !== selectedItem?.value) {\n        onValueChange?.(nextItem.value, nextItem);\n      }\n    },\n    [controlled, items, onValueChange, selectedItem?.value],\n  );\n\n  const preloadImage = React.useCallback((src: string) => {\n    if (typeof window === \"undefined\") return;\n\n    const image = new window.Image();\n    image.src = src;\n  }, []);\n\n  const preloadScene = React.useCallback(\n    (item: SlidingListItem) => {\n      const scene = items.find((candidate) => candidate.value === item.value);\n\n      if (scene) {\n        preloadImage(scene.image.src);\n      }\n    },\n    [items, preloadImage],\n  );\n\n  if (!selectedItem) {\n    return (\n      <section\n        data-slot=\"vertical-scene\"\n        className={cn(\"vertical-scene vertical-scene--empty\", className)}\n        {...props}\n      >\n        <p>{emptyLabel}</p>\n      </section>\n    );\n  }\n\n  const activeTabId = `${reactId}-tab-${selectedIndex}`;\n  const mediaTransition = {\n    duration: shouldReduceMotion ? 0 : MEDIA_DURATION,\n    ease: MEDIA_EASE,\n  };\n\n  return (\n    <section\n      data-slot=\"vertical-scene\"\n      data-direction={direction === 1 ? \"down\" : \"up\"}\n      data-motion={shouldReduceMotion ? \"reduced\" : \"full\"}\n      className={cn(\"vertical-scene\", className)}\n      {...props}\n    >\n      <div\n        className=\"vertical-scene__media\"\n        role=\"img\"\n        aria-label={selectedItem.image.alt}\n      >\n        <AnimatePresence\n          initial={false}\n          mode=\"sync\"\n          custom={motionDirection}\n        >\n          <motion.div\n            key={selectedItem.value}\n            className=\"vertical-scene__media-layer\"\n            custom={motionDirection}\n            variants={mediaLayerVariants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={mediaTransition}\n          >\n            <motion.img\n              aria-hidden=\"true\"\n              alt=\"\"\n              src={selectedItem.image.src}\n              draggable={false}\n              className=\"vertical-scene__image\"\n              style={{ objectPosition: selectedItem.imagePosition }}\n              custom={motionDirection}\n              variants={mediaImageVariants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={mediaTransition}\n            />\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      <div aria-hidden=\"true\" className=\"vertical-scene__scrim\" />\n\n      <div className=\"vertical-scene__chrome\">\n        <header className=\"vertical-scene__header\">\n          <p>{stageLabel}</p>\n          <p aria-live=\"polite\" aria-atomic=\"true\">\n            <span className=\"sr-only\">Scene </span>\n            {selectedIndex + 1}\n            <span aria-hidden=\"true\"> of </span>\n            <span className=\"sr-only\">of </span>\n            {items.length}\n          </p>\n        </header>\n\n        <div className=\"vertical-scene__body\">\n          <SlidingList\n            items={selectorItems}\n            value={selectedValue}\n            onValueChange={selectValue}\n            onItemPointerEnter={preloadScene}\n            onItemFocus={preloadScene}\n            aria-label={selectorLabel}\n            className=\"vertical-scene__selector\"\n            itemClassName=\"vertical-scene__tab\"\n            indicatorClassName=\"vertical-scene__tab-indicator\"\n          />\n\n          <div\n            id={panelId}\n            role=\"tabpanel\"\n            aria-labelledby={activeTabId}\n            className=\"vertical-scene__content\"\n          >\n            <AnimatePresence\n              initial={false}\n              mode=\"popLayout\"\n              custom={motionDirection}\n            >\n              <motion.div\n                key={selectedItem.value}\n                custom={motionDirection}\n                variants={contentVariants}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                className=\"vertical-scene__content-inner\"\n              >\n                {selectedItem.context ? (\n                  <p className=\"vertical-scene__context\">\n                    {selectedItem.context}\n                  </p>\n                ) : null}\n                <h2>{selectedItem.title}</h2>\n                {selectedItem.description ? (\n                  <p className=\"vertical-scene__description\">\n                    {selectedItem.description}\n                  </p>\n                ) : null}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:block",
      "target": "components/blocks/vertical-scene.tsx"
    },
    {
      "path": "registry/base/blocks/vertical-scene.css",
      "content": ".vertical-scene {\n  container-type: inline-size;\n  position: relative;\n  isolation: isolate;\n  min-height: 32rem;\n  overflow: hidden;\n  background: oklch(0.12 0 0);\n  color: white;\n}\n\n.vertical-scene--empty {\n  display: grid;\n  min-height: 16rem;\n  place-items: center;\n  background: var(--muted);\n  color: var(--muted-foreground);\n  font-size: 0.875rem;\n}\n\n.vertical-scene__media,\n.vertical-scene__media-layer,\n.vertical-scene__image,\n.vertical-scene__scrim {\n  position: absolute;\n  inset: 0;\n}\n\n.vertical-scene__media {\n  z-index: -3;\n  overflow: hidden;\n  background: oklch(0.12 0 0);\n}\n\n.vertical-scene__media-layer {\n  overflow: hidden;\n  will-change: transform;\n}\n\n.vertical-scene__image {\n  width: 100%;\n  height: 100%;\n  user-select: none;\n  object-fit: cover;\n  will-change: transform;\n}\n\n.vertical-scene__scrim {\n  z-index: -2;\n  background:\n    linear-gradient(to right, rgb(0 0 0 / 76%), transparent 68%),\n    linear-gradient(to top, rgb(0 0 0 / 58%), transparent 50%),\n    linear-gradient(to bottom, rgb(0 0 0 / 38%), transparent 34%);\n}\n\n.vertical-scene__chrome {\n  display: grid;\n  height: 100%;\n  min-height: inherit;\n  grid-template-rows: auto 1fr;\n  gap: 2rem;\n  padding: 1.25rem;\n}\n\n.vertical-scene__header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1rem;\n  font-size: 0.8125rem;\n  font-weight: 550;\n  color: rgb(255 255 255 / 82%);\n}\n\n.vertical-scene__body {\n  display: flex;\n  min-width: 0;\n  flex-direction: column-reverse;\n  justify-content: flex-start;\n  gap: 2rem;\n}\n\n.vertical-scene__content {\n  display: flex;\n  min-width: 0;\n  min-height: 17rem;\n  align-items: flex-end;\n  max-width: 42rem;\n}\n\n.vertical-scene__content-inner {\n  width: min(100%, 48rem);\n}\n\n.vertical-scene__context {\n  margin: 0 0 0.875rem;\n  max-width: 60ch;\n  font-size: 0.8125rem;\n  font-weight: 600;\n  line-height: 1.4;\n  color: rgb(255 255 255 / 78%);\n}\n\n.vertical-scene__content h2 {\n  margin: 0;\n  max-width: 13ch;\n  font-size: clamp(2.25rem, 11cqw, 5rem);\n  font-weight: 620;\n  line-height: 0.98;\n  letter-spacing: -0.035em;\n  text-wrap: balance;\n}\n\n.vertical-scene__description {\n  margin: 1rem 0 0;\n  max-width: 58ch;\n  font-size: clamp(0.875rem, 2.7cqw, 1rem);\n  line-height: 1.6;\n  color: rgb(255 255 255 / 84%);\n  text-wrap: pretty;\n}\n\n.vertical-scene__selector {\n  min-width: 0;\n  color: white;\n}\n\n.vertical-scene__selector [data-slot=\"sliding-list-list\"] {\n  gap: 0.125rem;\n}\n\n.vertical-scene__selector .vertical-scene__tab {\n  min-height: 2.75rem;\n  border-radius: 0;\n  padding: 0.625rem 1rem 0.625rem 0.75rem;\n  color: rgb(255 255 255 / 68%);\n  font-size: 0.875rem;\n}\n\n.vertical-scene__selector .vertical-scene__tab:hover,\n.vertical-scene__selector .vertical-scene__tab[data-active] {\n  color: white;\n}\n\n.vertical-scene__selector .vertical-scene__tab:focus-visible {\n  outline: 2px solid white;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n@container (min-width: 44rem) {\n  .vertical-scene__chrome {\n    gap: 3rem;\n    padding: 1.75rem 2rem 2rem;\n  }\n\n  .vertical-scene__body {\n    display: grid;\n    grid-template-columns: minmax(10rem, 0.62fr) minmax(20rem, 1.38fr);\n    align-items: end;\n    gap: clamp(2rem, 7cqw, 6rem);\n  }\n\n  .vertical-scene__content {\n    min-height: 20rem;\n    justify-self: end;\n  }\n}\n",
      "type": "registry:file",
      "target": "components/blocks/vertical-scene.css"
    }
  ],
  "meta": {
    "tags": [
      "category",
      "gallery",
      "image",
      "storytelling",
      "tabs",
      "controlled",
      "directional"
    ],
    "effects": [
      "vertical-reveal",
      "media-zoom",
      "content-transition",
      "direction-aware",
      "reduced-motion"
    ]
  },
  "categories": [
    "marketing"
  ],
  "type": "registry:block"
}