{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-scene",
  "title": "Ripple Scene Gallery",
  "description": "An immersive scene selector whose photo lifts into a travelling corrugated wave, swapping to the next scene at the crest before settling flat.",
  "registryDependencies": [
    "https://ui.ericts.com/r/rail-list.json",
    "https://ui.ericts.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/base/blocks/ripple-scene.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { RailList, type RailListItem } from \"@/components/ui/rail-list\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nimport \"./ripple-scene.css\";\n\nexport type RippleSceneImage = {\n  src: string;\n  alt: string;\n};\n\nexport type RippleSceneItem = {\n  /** Stable value used by controlled state and callbacks. */\n  value: string;\n  /** Short label rendered in the horizontal selector. */\n  label: string;\n  /** Main scene heading. */\n  title: string;\n  /** Optional supporting copy below the heading. */\n  description?: string;\n  /** Optional context shown above the heading. */\n  overline?: string;\n  image: RippleSceneImage;\n  imagePosition?: React.CSSProperties[\"objectPosition\"];\n};\n\nexport interface RippleSceneProps\n  extends Omit<React.ComponentProps<\"section\">, \"onChange\"> {\n  items: readonly RippleSceneItem[];\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: RippleSceneItem) => void;\n  /** Accessible label for the scene selector. */\n  selectorLabel?: string;\n  /** Small persistent label in the stage header. */\n  stageLabel?: string;\n  /** Message displayed when no scenes are provided. */\n  emptyLabel?: string;\n}\n\n/** Number of vertical strips the wave is built from. */\nconst STRIP_COUNT = 12;\n/** Per-strip delay that makes the wave travel across the scene. */\nconst STRIP_STAGGER_MS = 26;\n/**\n * animationName of a strip's ripple. When the last strip in the wave\n * finishes, the new scene is promoted to the static base layer. Must match\n * the CSS keyframes name.\n */\nconst RIPPLE_SETTLE_ANIMATION = \"ripple-scene-strip\";\n/**\n * Safety net for when animationend never arrives (hidden tabs pause CSS\n * animations; user styles may disable them). Must exceed the full wave:\n * strip duration + last strip's delay.\n */\nconst SETTLE_FALLBACK_MS = 1600;\n\ntype MediaStage = {\n  /** Scene rendered as the static, untransformed base layer. */\n  base: string;\n  /** Scene currently rippling in above the base, if any. */\n  incoming: string | null;\n};\n\nexport function RippleScene({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  selectorLabel = \"Choose a scene\",\n  stageLabel = \"Selected stories\",\n  emptyLabel = \"No scenes available\",\n  className,\n  ...props\n}: RippleSceneProps) {\n  const reactId = React.useId();\n  const panelId = `${reactId}-panel`;\n  const shouldReduceMotion = useReducedMotion();\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\n  // The previous scene stays put as a static base layer while the next scene\n  // ripples in above it, so at most one scene is ever animating.\n  const [media, setMedia] = React.useState<MediaStage>({\n    base: selectedValue,\n    incoming: null,\n  });\n  const shownValue = media.incoming ?? media.base;\n  const selectedSrc = selectedItem?.image.src;\n\n  React.useEffect(() => {\n    if (!selectedSrc || shownValue === selectedValue) return;\n\n    let cancelled = false;\n\n    // Decode off-screen first so the wave never competes with image decoding.\n    const commit = () => {\n      if (cancelled) return;\n\n      setMedia((previous) =>\n        shouldReduceMotion\n          ? { base: selectedValue, incoming: null }\n          : { base: previous.incoming ?? previous.base, incoming: selectedValue },\n      );\n    };\n\n    const image = new window.Image();\n    image.src = selectedSrc;\n\n    if (typeof image.decode === \"function\") {\n      image.decode().then(commit, commit);\n    } else {\n      commit();\n    }\n\n    return () => {\n      cancelled = true;\n    };\n  }, [selectedSrc, selectedValue, shownValue, shouldReduceMotion]);\n\n  React.useEffect(() => {\n    if (!media.incoming) return;\n\n    const staged = media.incoming;\n    const timer = window.setTimeout(() => {\n      setMedia((previous) =>\n        previous.incoming === staged\n          ? { base: staged, incoming: null }\n          : previous,\n      );\n    }, SETTLE_FALLBACK_MS);\n\n    return () => {\n      window.clearTimeout(timer);\n    };\n  }, [media.incoming]);\n\n  const handleRippleSettle = React.useCallback(\n    (event: React.AnimationEvent<HTMLDivElement>) => {\n      if (event.animationName !== RIPPLE_SETTLE_ANIMATION) return;\n\n      setMedia((previous) =>\n        previous.incoming\n          ? { base: previous.incoming, incoming: null }\n          : previous,\n      );\n    },\n    [],\n  );\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 preloadScene = React.useCallback(\n    (railItem: RailListItem) => {\n      if (typeof window === \"undefined\") return;\n\n      const src = items.find((item) => item.value === railItem.value)?.image\n        .src;\n\n      if (!src) return;\n\n      const image = new window.Image();\n      image.src = src;\n      image.decode?.().catch(() => {});\n    },\n    [items],\n  );\n\n  const selectorItems = React.useMemo<RailListItem[]>(\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\n  if (!selectedItem) {\n    return (\n      <section\n        data-slot=\"ripple-scene\"\n        className={cn(\"ripple-scene ripple-scene--empty\", className)}\n        {...props}\n      >\n        <p>{emptyLabel}</p>\n      </section>\n    );\n  }\n\n  const activeTabId = `${reactId}-tab-${selectedIndex}`;\n  const baseItem =\n    items.find((item) => item.value === media.base) ?? selectedItem;\n  const incomingItem = media.incoming\n    ? items.find((item) => item.value === media.incoming)\n    : undefined;\n\n  // The wave travels toward the newly selected scene: forward selections\n  // sweep left-to-right, backward selections sweep right-to-left.\n  const baseIndex = items.findIndex((item) => item.value === media.base);\n  const incomingIndex = incomingItem\n    ? items.findIndex((item) => item.value === incomingItem.value)\n    : -1;\n  const forward = incomingIndex >= baseIndex;\n  const settleStrip = forward ? STRIP_COUNT - 1 : 0;\n  const stripWidth = 100 / STRIP_COUNT;\n\n  return (\n    <section\n      data-slot=\"ripple-scene\"\n      data-motion={shouldReduceMotion ? \"reduced\" : \"full\"}\n      className={cn(\"ripple-scene\", className)}\n      {...props}\n    >\n      <div\n        className=\"ripple-scene__media\"\n        role=\"img\"\n        aria-label={selectedItem.image.alt}\n      >\n        <div className=\"ripple-scene__scene ripple-scene__scene--base\">\n          {/* Registry blocks stay framework-neutral, so consumers can choose their own image loader. */}\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img\n            alt=\"\"\n            src={baseItem.image.src}\n            draggable={false}\n            className=\"ripple-scene__image\"\n            style={{ objectPosition: baseItem.imagePosition }}\n          />\n        </div>\n        {incomingItem ? (\n          <div\n            key={incomingItem.value}\n            aria-hidden=\"true\"\n            className=\"ripple-scene__scene ripple-scene__scene--ripple\"\n          >\n            {Array.from({ length: STRIP_COUNT }, (_, index) => {\n              const order = forward ? index : STRIP_COUNT - 1 - index;\n              // Strips overlap by a hair so no hairline shows once settled.\n              const left = Math.max(0, index * stripWidth - 0.06);\n              const right = Math.max(0, 100 - (index + 1) * stripWidth - 0.06);\n\n              return (\n                <div\n                  key={index}\n                  className=\"ripple-scene__strip\"\n                  data-settle={index === settleStrip ? \"true\" : undefined}\n                  onAnimationEnd={\n                    index === settleStrip ? handleRippleSettle : undefined\n                  }\n                  style={\n                    {\n                      clipPath: `inset(0 ${right}% 0 ${left}%)`,\n                      transformOrigin: `${(index + 0.5) * stripWidth}% 50%`,\n                      \"--ripple-scene-delay\": `${order * STRIP_STAGGER_MS}ms`,\n                    } as React.CSSProperties\n                  }\n                >\n                  {/* eslint-disable-next-line @next/next/no-img-element */}\n                  <img\n                    alt=\"\"\n                    src={incomingItem.image.src}\n                    draggable={false}\n                    className=\"ripple-scene__image\"\n                    style={{ objectPosition: incomingItem.imagePosition }}\n                  />\n                  {/* eslint-disable-next-line @next/next/no-img-element */}\n                  <img\n                    alt=\"\"\n                    src={baseItem.image.src}\n                    draggable={false}\n                    className=\"ripple-scene__image ripple-scene__face--out\"\n                    style={{ objectPosition: baseItem.imagePosition }}\n                  />\n                </div>\n              );\n            })}\n          </div>\n        ) : null}\n      </div>\n\n      <div aria-hidden=\"true\" className=\"ripple-scene__scrim\" />\n\n      <div className=\"ripple-scene__chrome\">\n        <header className=\"ripple-scene__header\">\n          <p>{stageLabel}</p>\n          <p aria-live=\"polite\" aria-atomic=\"true\">\n            <span className=\"sr-only\">Scene </span>\n            {String(selectedIndex + 1).padStart(2, \"0\")}\n            <span aria-hidden=\"true\"> / </span>\n            <span className=\"sr-only\">of </span>\n            {String(items.length).padStart(2, \"0\")}\n          </p>\n        </header>\n\n        <div\n          id={panelId}\n          role=\"tabpanel\"\n          aria-labelledby={activeTabId}\n          className=\"ripple-scene__content\"\n        >\n          <div key={selectedItem.value} className=\"ripple-scene__content-inner\">\n            {selectedItem.overline ? (\n              <p className=\"ripple-scene__overline\">{selectedItem.overline}</p>\n            ) : null}\n            <h2>{selectedItem.title}</h2>\n            {selectedItem.description ? (\n              <p className=\"ripple-scene__description\">\n                {selectedItem.description}\n              </p>\n            ) : null}\n          </div>\n        </div>\n\n        <RailList\n          items={selectorItems}\n          value={selectedValue}\n          onValueChange={selectValue}\n          onItemPointerEnter={preloadScene}\n          onItemFocus={preloadScene}\n          aria-label={selectorLabel}\n          edge=\"top\"\n          className=\"ripple-scene__selector\"\n          itemClassName=\"ripple-scene__tab\"\n          indicatorClassName=\"ripple-scene__tab-indicator\"\n        />\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:block",
      "target": "components/blocks/ripple-scene.tsx"
    },
    {
      "path": "registry/base/blocks/ripple-scene.css",
      "content": ".ripple-scene {\n  --ripple-scene-duration: 520ms;\n  --ripple-scene-content-duration: 420ms;\n  --ripple-scene-ease: cubic-bezier(0.16, 1, 0.3, 1);\n\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.ripple-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.ripple-scene__media,\n.ripple-scene__scene,\n.ripple-scene__strip,\n.ripple-scene__image,\n.ripple-scene__scrim {\n  position: absolute;\n  inset: 0;\n}\n\n.ripple-scene__media {\n  z-index: -3;\n  overflow: hidden;\n  background: oklch(0.12 0 0);\n}\n\n/* The outgoing scene never animates — it just sits beneath the wave. */\n.ripple-scene__scene--ripple {\n  perspective: 60rem;\n}\n\n/*\n * Each strip carries the new image with the old image stacked on top. Before\n * its delay elapses the strip is flat and shows the old face, so the surface\n * starts out identical to the base scene — the wave then lifts each strip\n * into the corrugation, the faces cross over at the crest, and the strip\n * lands flat as part of the new scene.\n */\n.ripple-scene__strip {\n  --ripple-scene-tilt: 13deg;\n  --ripple-scene-lift: 2.4rem;\n  --ripple-scene-dip: 0.55%;\n\n  will-change: transform;\n  animation: ripple-scene-strip var(--ripple-scene-duration)\n    cubic-bezier(0.36, 0, 0.22, 1) both;\n  animation-delay: var(--ripple-scene-delay, 0ms);\n}\n\n/* Alternating tilt and depth turn the travelling wave into a corrugation. */\n.ripple-scene__strip:nth-child(even) {\n  --ripple-scene-tilt: -13deg;\n  --ripple-scene-lift: -2.4rem;\n  --ripple-scene-dip: -0.55%;\n}\n\n/* Old face rides the strip and hands over to the new face at the crest. */\n.ripple-scene__face--out {\n  animation: ripple-scene-swap var(--ripple-scene-duration) linear both;\n  animation-delay: var(--ripple-scene-delay, 0ms);\n}\n\n/* Corrugation lighting: raised strips catch light, sunken strips shade. */\n.ripple-scene__strip::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: rgb(255 255 255 / 14%);\n  opacity: 0;\n  animation: ripple-scene-shade var(--ripple-scene-duration)\n    cubic-bezier(0.36, 0, 0.22, 1) both;\n  animation-delay: var(--ripple-scene-delay, 0ms);\n}\n\n.ripple-scene__strip:nth-child(even)::after {\n  background: rgb(0 0 0 / 26%);\n}\n\n@keyframes ripple-scene-strip {\n  0% {\n    transform: translate3d(0, 0, 0) rotateY(0deg);\n    animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);\n  }\n  45% {\n    transform: translate3d(0, var(--ripple-scene-dip), var(--ripple-scene-lift))\n      rotateY(var(--ripple-scene-tilt));\n    animation-timing-function: cubic-bezier(0.34, 0, 0.22, 1);\n  }\n  100% {\n    transform: translate3d(0, 0, 0) rotateY(0deg);\n  }\n}\n\n@keyframes ripple-scene-swap {\n  0%,\n  32% {\n    opacity: 1;\n  }\n  60%,\n  100% {\n    opacity: 0;\n  }\n}\n\n@keyframes ripple-scene-shade {\n  0% {\n    opacity: 0;\n  }\n  45% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0;\n  }\n}\n\n.ripple-scene__image {\n  width: 100%;\n  height: 100%;\n  user-select: none;\n  object-fit: cover;\n}\n\n.ripple-scene__scrim {\n  z-index: -2;\n  background:\n    linear-gradient(to top, rgb(0 0 0 / 78%), transparent 62%),\n    linear-gradient(to right, rgb(0 0 0 / 72%), transparent 68%),\n    linear-gradient(to bottom, rgb(0 0 0 / 42%), transparent 32%);\n}\n\n.ripple-scene__chrome {\n  display: grid;\n  height: 100%;\n  min-height: inherit;\n  grid-template-rows: auto 1fr auto;\n  gap: 1.5rem;\n  padding: 1.25rem;\n}\n\n.ripple-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/* The selector is a RailList; these classes restyle its slots for the stage. */\n.ripple-scene__selector {\n  border-top: 1px solid rgb(255 255 255 / 24%);\n}\n\n.ripple-scene__tab {\n  min-height: 3rem;\n  padding: 0.875rem 0.75rem 0.75rem;\n  color: rgb(255 255 255 / 68%);\n  font-size: 0.8125rem;\n  font-weight: 550;\n  line-height: 1.25;\n}\n\n.ripple-scene__tab:hover,\n.ripple-scene__tab[data-active] {\n  color: white;\n}\n\n/* Sit the indicator on the rail's top border. */\n.ripple-scene__tab-indicator {\n  top: -1px;\n  right: 0.75rem;\n  left: 0.75rem;\n}\n\n.ripple-scene__content {\n  display: flex;\n  min-width: 0;\n  align-items: center;\n}\n\n.ripple-scene__content-inner {\n  width: min(100%, 48rem);\n}\n\n.ripple-scene__content-inner > * {\n  animation: ripple-scene-rise var(--ripple-scene-content-duration)\n    var(--ripple-scene-ease) both;\n}\n\n.ripple-scene__content-inner > :nth-child(2) {\n  animation-delay: 55ms;\n}\n\n.ripple-scene__content-inner > :nth-child(3) {\n  animation-delay: 110ms;\n}\n\n@keyframes ripple-scene-rise {\n  from {\n    opacity: 0;\n    transform: translateY(14px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.ripple-scene__overline {\n  margin: 0 0 0.75rem;\n  max-width: 58ch;\n  font-size: 0.8125rem;\n  font-weight: 600;\n  line-height: 1.4;\n  color: rgb(255 255 255 / 76%);\n}\n\n.ripple-scene__content h2 {\n  margin: 0;\n  max-width: 12ch;\n  font-size: clamp(2.25rem, 11cqw, 5.5rem);\n  font-weight: 620;\n  line-height: 0.96;\n  letter-spacing: -0.035em;\n  text-wrap: balance;\n}\n\n.ripple-scene__description {\n  margin: 1rem 0 0;\n  max-width: 58ch;\n  font-size: clamp(0.875rem, 2.8cqw, 1rem);\n  line-height: 1.6;\n  color: rgb(255 255 255 / 82%);\n  text-wrap: pretty;\n}\n\n@container (min-width: 46rem) {\n  .ripple-scene__chrome {\n    gap: 2rem;\n    padding: 1.75rem 2rem 1rem;\n  }\n\n  .ripple-scene__content {\n    padding-left: clamp(1rem, 6cqw, 6rem);\n  }\n\n  .ripple-scene__tab {\n    min-width: 8rem;\n    padding-inline: 1rem;\n  }\n\n  .ripple-scene__tab-indicator {\n    right: 1rem;\n    left: 1rem;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ripple-scene__strip,\n  .ripple-scene__strip::after,\n  .ripple-scene__face--out,\n  .ripple-scene__content-inner > * {\n    animation: none;\n  }\n}\n",
      "type": "registry:file",
      "target": "components/blocks/ripple-scene.css"
    }
  ],
  "meta": {
    "tags": [
      "category",
      "gallery",
      "image",
      "storytelling",
      "tabs",
      "controlled",
      "directional"
    ],
    "effects": [
      "ripple-wave",
      "strip-stagger",
      "crest-swap",
      "direction-aware",
      "content-stagger",
      "reduced-motion"
    ]
  },
  "categories": [
    "marketing"
  ],
  "type": "registry:block"
}