{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "depth-tiles",
  "type": "registry:ui",
  "title": "Depth Tiles",
  "description": "An infinite layered tile stack with autoplay, depth transitions, and horizontal drag navigation.",
  "files": [
    {
      "path": "registry/sonaui/depth-tiles/depth-tiles.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface DepthTileItem {\n  /** Stable identifier used to preserve the tile across reorders. */\n  id: string;\n  /** Image URL rendered by the tile. */\n  image: string;\n  /** Accessible description for the image. */\n  alt: string;\n  /** Small label shown at the bottom of the tile. */\n  label: string;\n}\n\nexport interface DepthTilesProps {\n  /** Tiles shown in the infinite stack. */\n  items: DepthTileItem[];\n  /** Controlled active tile index. @default undefined */\n  index?: number;\n  /** Initial active tile index when uncontrolled. @default 0 */\n  defaultIndex?: number;\n  /** Called after the active tile changes. @default undefined */\n  onIndexChange?: (index: number) => void;\n  /** Enables automatic forward movement. @default true */\n  autoplay?: boolean;\n  /** Time between automatic advances in milliseconds. @default 3200 */\n  interval?: number;\n  /** Pauses autoplay while the pointer is over the component. @default true */\n  pauseOnHover?: boolean;\n  /** Enables horizontal pointer drag and touch swipe. @default true */\n  draggable?: boolean;\n  /** Accessible label for the carousel region. @default \"Depth tiles\" */\n  ariaLabel?: string;\n  /** Additional classes for the component. @default undefined */\n  className?: string;\n}\n\nconst SPRING = {\n  type: \"spring\",\n  stiffness: 330,\n  damping: 36,\n  mass: 0.85,\n} as const;\nconst TAU = Math.PI * 2;\n\nconst wrapIndex = (value: number, length: number) =>\n  ((value % length) + length) % length;\n\nconst roundStyleValue = (value: number) => Math.round(value * 1000) / 1000;\n\nfunction getShortestDelta(itemIndex: number, progress: number, length: number) {\n  if (length <= 1) return 0;\n  const half = length / 2;\n  return ((((itemIndex - progress + half) % length) + length) % length) - half;\n}\n\nfunction DepthTile({\n  item,\n  itemIndex,\n  itemCount,\n  progress,\n  orbitRadius,\n  hydrated,\n}: {\n  item: DepthTileItem;\n  itemIndex: number;\n  itemCount: number;\n  progress: MotionValue<number>;\n  orbitRadius: number;\n  hydrated: boolean;\n}) {\n  const phase = useTransform(\n    progress,\n    (value) => ((itemIndex - value) / itemCount) * TAU,\n  );\n  const depth = useTransform(phase, (value) => (1 - Math.cos(value)) / 2);\n  const x = useTransform(phase, (value) => Math.sin(value) * orbitRadius);\n  const y = useTransform(depth, (value) => value * 28);\n  const scale = useTransform(depth, (value) => 1 - value * 0.2);\n  const opacity = useTransform(depth, (value) => 1 - value * 0.64);\n  const zIndex = useTransform(depth, (value) => Math.round((1 - value) * 100));\n  const initialPhase = ((itemIndex - progress.get()) / itemCount) * TAU;\n  const initialDepth = (1 - Math.cos(initialPhase)) / 2;\n  const initialStyle = {\n    opacity: roundStyleValue(1 - initialDepth * 0.64),\n    transform: `translateX(${roundStyleValue(Math.sin(initialPhase) * orbitRadius)}px) translateY(${roundStyleValue(initialDepth * 28)}px) scale(${roundStyleValue(1 - initialDepth * 0.2)})`,\n    zIndex: Math.round((1 - initialDepth) * 100),\n  };\n\n  return (\n    <motion.article\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute aspect-[1.12] w-[min(68vw,25rem)] overflow-hidden rounded-[1.4rem] bg-muted shadow-[0_2px_8px_rgba(0,0,0,.08),0_24px_64px_rgba(0,0,0,.18)] will-change-transform sm:rounded-[1.75rem]\"\n      style={hydrated ? { x, y, scale, opacity, zIndex } : initialStyle}\n    >\n      {/* biome-ignore lint/performance/noImgElement: framework-neutral registry component */}\n      <img\n        src={item.image}\n        alt=\"\"\n        draggable={false}\n        className=\"h-full w-full object-cover\"\n      />\n      <div className=\"absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/72 via-black/22 to-transparent px-5 pb-5 pt-20 text-white\">\n        <span className=\"text-base font-medium tracking-[-0.015em] sm:text-lg\">\n          {item.label}\n        </span>\n      </div>\n    </motion.article>\n  );\n}\n\nexport default function DepthTiles({\n  items,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  autoplay = true,\n  interval = 3200,\n  pauseOnHover = true,\n  draggable = true,\n  ariaLabel = \"Depth tiles\",\n  className,\n}: DepthTilesProps) {\n  const reduceMotion = Boolean(useReducedMotion());\n  const descriptionId = useId();\n  const isControlled = index !== undefined;\n  const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);\n  const [isHovered, setIsHovered] = useState(false);\n  const [isFocused, setIsFocused] = useState(false);\n  const [isDragging, setIsDragging] = useState(false);\n  const [isDocumentVisible, setIsDocumentVisible] = useState(true);\n  const [hydrated, setHydrated] = useState(false);\n  const [viewportWidth, setViewportWidth] = useState(960);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const itemCount = items.length;\n  const activeIndex = itemCount\n    ? wrapIndex(isControlled ? index : uncontrolledIndex, itemCount)\n    : 0;\n  const progress = useMotionValue(activeIndex);\n  const dragStartProgress = useRef(activeIndex);\n  const animationRef = useRef<ReturnType<typeof animate> | null>(null);\n\n  useEffect(() => setHydrated(true), []);\n\n  const stopAnimation = useCallback(() => {\n    animationRef.current?.stop();\n    animationRef.current = null;\n  }, []);\n\n  const commitIndex = useCallback(\n    (nextIndex: number) => {\n      if (!itemCount) return;\n      const wrapped = wrapIndex(nextIndex, itemCount);\n      if (!isControlled) setUncontrolledIndex(wrapped);\n      if (wrapped !== activeIndex) onIndexChange?.(wrapped);\n    },\n    [activeIndex, isControlled, itemCount, onIndexChange],\n  );\n\n  const settleTo = useCallback(\n    (targetProgress: number) => {\n      stopAnimation();\n      const targetIndex = Math.round(targetProgress);\n      if (reduceMotion) {\n        progress.set(targetIndex);\n        commitIndex(targetIndex);\n        return;\n      }\n      animationRef.current = animate(progress, targetIndex, {\n        ...SPRING,\n        onComplete: () => {\n          animationRef.current = null;\n          commitIndex(targetIndex);\n        },\n      });\n    },\n    [commitIndex, progress, reduceMotion, stopAnimation],\n  );\n\n  const moveBy = useCallback(\n    (direction: 1 | -1) => settleTo(progress.get() + direction),\n    [progress, settleTo],\n  );\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    const observer = new ResizeObserver(([entry]) => {\n      setViewportWidth(entry.contentRect.width);\n    });\n    observer.observe(viewport);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const handleVisibilityChange = () =>\n      setIsDocumentVisible(document.visibilityState === \"visible\");\n    handleVisibilityChange();\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n  }, []);\n\n  useEffect(() => {\n    if (!itemCount) return;\n    const current = progress.get();\n    if (wrapIndex(Math.round(current), itemCount) === activeIndex) return;\n    stopAnimation();\n    const target = current + getShortestDelta(activeIndex, current, itemCount);\n    if (reduceMotion) progress.set(target);\n    else animationRef.current = animate(progress, target, SPRING);\n  }, [activeIndex, itemCount, progress, reduceMotion, stopAnimation]);\n\n  useEffect(() => {\n    if (\n      !autoplay ||\n      reduceMotion ||\n      !isDocumentVisible ||\n      isDragging ||\n      isFocused ||\n      (pauseOnHover && isHovered) ||\n      itemCount < 2\n    )\n      return;\n    const timer = window.setTimeout(() => moveBy(1), Math.max(interval, 800));\n    return () => window.clearTimeout(timer);\n  }, [\n    autoplay,\n    interval,\n    isDocumentVisible,\n    isDragging,\n    isFocused,\n    isHovered,\n    itemCount,\n    moveBy,\n    pauseOnHover,\n    reduceMotion,\n  ]);\n\n  useEffect(() => stopAnimation, [stopAnimation]);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      moveBy(1);\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      moveBy(-1);\n    }\n  };\n\n  if (!itemCount) return null;\n  const activeItem = items[activeIndex];\n  const orbitRadius = Math.min(292, Math.max(126, viewportWidth * 0.31));\n  const dragStep = Math.min(280, Math.max(180, viewportWidth * 0.34));\n\n  return (\n    <section\n      aria-label={ariaLabel}\n      aria-describedby={descriptionId}\n      aria-roledescription=\"carousel\"\n      tabIndex={itemCount > 1 ? 0 : undefined}\n      className={cn(\"w-full\", className)}\n      onFocusCapture={() => setIsFocused(true)}\n      onBlurCapture={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget))\n          setIsFocused(false);\n      }}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      onKeyDown={handleKeyDown}\n    >\n      <div\n        ref={viewportRef}\n        className=\"relative mx-auto flex h-[22rem] w-full max-w-5xl items-center justify-center overflow-hidden sm:h-[31rem]\"\n      >\n        {!reduceMotion &&\n          items.map((item, itemIndex) => (\n            <DepthTile\n              key={item.id}\n              item={item}\n              itemIndex={itemIndex}\n              itemCount={itemCount}\n              progress={progress}\n              orbitRadius={orbitRadius}\n              hydrated={hydrated}\n            />\n          ))}\n\n        {reduceMotion && (\n          <article className=\"pointer-events-none absolute aspect-[1.12] w-[min(68vw,25rem)] overflow-hidden rounded-[1.4rem] bg-muted shadow-[0_2px_8px_rgba(0,0,0,.08),0_24px_64px_rgba(0,0,0,.18)] sm:rounded-[1.75rem]\">\n            {/* biome-ignore lint/performance/noImgElement: framework-neutral registry component */}\n            <img\n              src={activeItem.image}\n              alt=\"\"\n              draggable={false}\n              className=\"h-full w-full object-cover\"\n            />\n            <div className=\"absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/72 via-black/22 to-transparent px-5 pb-5 pt-20 text-white\">\n              <span className=\"text-base font-medium tracking-[-0.015em] sm:text-lg\">\n                {activeItem.label}\n              </span>\n            </div>\n          </article>\n        )}\n\n        {draggable && itemCount > 1 && !reduceMotion && (\n          <motion.div\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 z-[120] cursor-grab touch-pan-y active:cursor-grabbing\"\n            drag=\"x\"\n            dragConstraints={{ left: 0, right: 0 }}\n            dragElastic={0.08}\n            onDragStart={() => {\n              stopAnimation();\n              dragStartProgress.current = progress.get();\n              setIsDragging(true);\n            }}\n            onDrag={(_, info) =>\n              progress.set(dragStartProgress.current - info.offset.x / dragStep)\n            }\n            onDragEnd={(_, info) => {\n              const distance = Math.abs(info.offset.x);\n              const velocity = Math.abs(info.velocity.x);\n              const shouldAdvance = distance > 44 || velocity > 420;\n              const direction =\n                velocity > 420\n                  ? info.velocity.x < 0\n                    ? 1\n                    : -1\n                  : info.offset.x < 0\n                    ? 1\n                    : -1;\n              setIsDragging(false);\n              settleTo(\n                shouldAdvance\n                  ? Math.round(dragStartProgress.current) + direction\n                  : Math.round(dragStartProgress.current),\n              );\n            }}\n          />\n        )}\n      </div>\n\n      <div\n        id={descriptionId}\n        className=\"sr-only\"\n        aria-live={isFocused ? \"polite\" : \"off\"}\n        aria-atomic=\"true\"\n      >\n        {activeItem.alt}. {activeItem.label}, item {activeIndex + 1} of{\" \"}\n        {itemCount}\n        {itemCount > 1\n          ? \". Use the left and right arrow keys to navigate.\"\n          : \"\"}\n      </div>\n    </section>\n  );\n}\n\nexport { DepthTiles };\n",
      "target": "components/ui/depth-tiles/depth-tiles.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}