{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A TimeMachineStack component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  motion,\n  type TargetAndTransition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_DEPTH = 50;\nconst DEFAULT_OFFSET_Y = 36;\nconst DEFAULT_SCALE_STEP = 0.05;\nconst DEFAULT_VISIBLE_COUNT = 4;\nconst DEFAULT_PERSPECTIVE = 1400;\nconst WHEEL_THRESHOLD = 12;\nconst WHEEL_COOLDOWN_MS = 220;\nconst DRAG_THRESHOLD = 48;\nconst FORWARD = 1;\nconst BACKWARD = -1;\nconst EXIT_ENTER_TRANSITION = {\n  duration: 0.25,\n  ease: [0.23, 1, 0.32, 1] as const,\n};\n\nexport interface TimeMachineStackItem {\n  content: ReactNode;\n  id: string;\n}\n\nexport interface TimeMachineStackProps {\n  className?: string;\n  /** Distance (px) each receding panel is pushed back on the z-axis. */\n  depth?: number;\n  /** Controlled index of the front-most item. */\n  index?: number;\n  items: TimeMachineStackItem[];\n  /** Vertical offset (px) applied per receding panel. */\n  offsetY?: number;\n  onIndexChange?: (index: number) => void;\n  /** CSS perspective (px) applied to the stack's 3D context. */\n  perspective?: number;\n  /** Scale reduction applied per receding panel. */\n  scaleStep?: number;\n  /** Number of panels rendered at once; the rest stay unmounted. */\n  visibleCount?: number;\n}\n\ntype PanelState = TargetAndTransition & {\n  opacity: number;\n  transform: string;\n};\n\nconst clampIndex = (value: number, max: number): number =>\n  Math.min(Math.max(value, 0), max);\n\n/**\n * One `transform` string rather than Motion's `y`/`z`/`scale` shorthands: the\n * shorthands are implemented with CSS variables, which the compositor will not\n * accelerate, and a stack of full-bleed images under `perspective` is exactly\n * the case that wants to stay off the main thread.\n */\nconst toTransform = (y: number, z: number, scale: number): string =>\n  `translateY(${y}px) translateZ(${z}px) scale(${scale})`;\n\nexport default function TimeMachineStack({\n  className,\n  depth = DEFAULT_DEPTH,\n  index,\n  items,\n  offsetY = DEFAULT_OFFSET_Y,\n  onIndexChange,\n  perspective = DEFAULT_PERSPECTIVE,\n  scaleStep = DEFAULT_SCALE_STEP,\n  visibleCount = DEFAULT_VISIBLE_COUNT,\n}: TimeMachineStackProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const listboxId = useId();\n  const lastIndex = Math.max(items.length - 1, 0);\n  const isControlled = index !== undefined;\n  const [internalIndex, setInternalIndex] = useState(0);\n  const currentIndex = clampIndex(\n    isControlled ? (index as number) : internalIndex,\n    lastIndex\n  );\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const wheelLockRef = useRef(false);\n  const dragOriginRef = useRef<number | null>(null);\n\n  // Travel direction drives both enter and exit: moving forward the front\n  // panel flies past the viewer, moving back it falls in from the front.\n  // Derived from the index itself so it stays correct when `index` is\n  // controlled from the outside instead of by this component's handlers.\n  const previousIndexRef = useRef(currentIndex);\n  const directionRef = useRef(FORWARD);\n  if (previousIndexRef.current !== currentIndex) {\n    directionRef.current =\n      currentIndex > previousIndexRef.current ? FORWARD : BACKWARD;\n    previousIndexRef.current = currentIndex;\n  }\n  const direction = directionRef.current;\n\n  const goTo = useCallback(\n    (next: number) => {\n      const clamped = clampIndex(next, lastIndex);\n      if (clamped === currentIndex) {\n        return;\n      }\n      if (!isControlled) {\n        setInternalIndex(clamped);\n      }\n      onIndexChange?.(clamped);\n    },\n    [currentIndex, isControlled, lastIndex, onIndexChange]\n  );\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n    const handleNativeWheel = (event: WheelEvent) => {\n      if (Math.abs(event.deltaY) < WHEEL_THRESHOLD || wheelLockRef.current) {\n        return;\n      }\n      const next = currentIndex + (event.deltaY > 0 ? FORWARD : BACKWARD);\n      // At either end the stack hands the wheel back to the page instead of\n      // swallowing it, so the demo never traps the document scroll.\n      if (next < 0 || next > lastIndex) {\n        return;\n      }\n      event.preventDefault();\n      wheelLockRef.current = true;\n      goTo(next);\n      setTimeout(() => {\n        wheelLockRef.current = false;\n      }, WHEEL_COOLDOWN_MS);\n    };\n    container.addEventListener(\"wheel\", handleNativeWheel, { passive: false });\n    return () => container.removeEventListener(\"wheel\", handleNativeWheel);\n  }, [currentIndex, goTo, lastIndex]);\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        goTo(currentIndex + FORWARD);\n      } else if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        goTo(currentIndex + BACKWARD);\n      } else if (event.key === \"Home\") {\n        event.preventDefault();\n        goTo(0);\n      } else if (event.key === \"End\") {\n        event.preventDefault();\n        goTo(lastIndex);\n      }\n    },\n    [currentIndex, goTo, lastIndex]\n  );\n\n  const handlePointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      dragOriginRef.current = event.clientY;\n    },\n    []\n  );\n\n  const handlePointerUp = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const origin = dragOriginRef.current;\n      dragOriginRef.current = null;\n      if (origin === null) {\n        return;\n      }\n      const delta = origin - event.clientY;\n      if (Math.abs(delta) < DRAG_THRESHOLD) {\n        return;\n      }\n      goTo(currentIndex + (delta > 0 ? FORWARD : BACKWARD));\n    },\n    [currentIndex, goTo]\n  );\n\n  const handlePointerCancel = useCallback(() => {\n    dragOriginRef.current = null;\n  }, []);\n\n  // Depth is scale, offset, z-translation and shadow — never opacity. A panel\n  // that is see-through stops occluding the one behind it, and a stack that\n  // does not occlude reads as a pile of ghosts rather than a stack of objects.\n  // Opacity is reserved for the two positions outside the visible window\n  // (`pos < 0` and `pos >= visibleCount`), which is the only place a fade is\n  // describing something real: a panel leaving or entering the window.\n  const getPanelState = useCallback(\n    (pos: number): PanelState => {\n      if (shouldReduceMotion) {\n        return { opacity: pos === 0 ? 1 : 0, transform: toTransform(0, 0, 1) };\n      }\n      if (pos < 0) {\n        return {\n          opacity: 0,\n          transform: toTransform(offsetY * 2, depth * 2, 1 + scaleStep * 2),\n        };\n      }\n      return {\n        opacity: pos >= visibleCount ? 0 : 1,\n        transform: toTransform(\n          -pos * offsetY,\n          -pos * depth,\n          1 - pos * scaleStep\n        ),\n      };\n    },\n    [depth, offsetY, scaleStep, shouldReduceMotion, visibleCount]\n  );\n\n  const visibleItems = items\n    .slice(currentIndex, currentIndex + visibleCount)\n    .map((item, pos) => ({ item, pos }));\n  const frontItem = visibleItems[0]?.item;\n\n  // Receding panels translate up, so the stack reserves that much headroom and\n  // anchors every panel to the bottom edge. Without it the deeper panels are\n  // pushed outside the box and the \"stack\" reads as a single flat card.\n  const reservedTop = shouldReduceMotion\n    ? 0\n    : Math.max(visibleCount - 1, 0) * offsetY;\n\n  const transition = shouldReduceMotion\n    ? { duration: 0 }\n    : { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\n\n  const exitState =\n    direction === FORWARD ? getPanelState(-1) : getPanelState(visibleCount);\n\n  return (\n    // `w-full` is load-bearing: every panel is absolutely positioned, so the\n    // stack has no intrinsic width. Inside any shrink-to-fit parent (the docs\n    // preview centres its children) it would otherwise collapse to 0px wide.\n    <div className={cn(\"flex w-full flex-col items-center gap-4\", className)}>\n      {/* biome-ignore lint/a11y/useSemanticElements: a listbox of 3D panels has no native equivalent */}\n      <div\n        aria-activedescendant={\n          frontItem ? `${listboxId}-${frontItem.id}` : undefined\n        }\n        aria-label=\"Time machine stack\"\n        className=\"relative min-h-[14rem] w-full flex-1 touch-none select-none rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n        id={listboxId}\n        onKeyDown={handleKeyDown}\n        onPointerCancel={handlePointerCancel}\n        onPointerDown={handlePointerDown}\n        onPointerUp={handlePointerUp}\n        ref={containerRef}\n        role=\"listbox\"\n        style={{\n          perspective: shouldReduceMotion ? undefined : perspective,\n          touchAction: \"none\",\n        }}\n        tabIndex={0}\n      >\n        <AnimatePresence initial={false}>\n          {visibleItems\n            .slice()\n            .reverse()\n            .map(({ item, pos }) => {\n              const state = getPanelState(pos);\n              const isFront = pos === 0;\n              return (\n                <motion.div\n                  animate={{\n                    opacity: state.opacity,\n                    transform: state.transform,\n                  }}\n                  aria-selected={isFront}\n                  className=\"absolute inset-x-0 bottom-0 overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-[0_18px_45px_-20px_rgba(0,0,0,0.45)]\"\n                  exit={{\n                    opacity: 0,\n                    transform: exitState.transform,\n                    transition: shouldReduceMotion\n                      ? { duration: 0 }\n                      : EXIT_ENTER_TRANSITION,\n                  }}\n                  id={`${listboxId}-${item.id}`}\n                  // A panel only ever mounts in one of two places: one slot\n                  // behind the deepest one when travelling forward, or in front\n                  // of the stack when travelling back. Deriving it from `pos`\n                  // handed every already-mounted panel a start state it can\n                  // never legitimately occupy, which is only ever a chance for\n                  // a stale value to be read back.\n                  initial={\n                    direction === FORWARD\n                      ? getPanelState(visibleCount)\n                      : getPanelState(-1)\n                  }\n                  key={item.id}\n                  role=\"option\"\n                  style={{ top: reservedTop, zIndex: visibleCount - pos }}\n                  transition={transition}\n                >\n                  {item.content}\n                </motion.div>\n              );\n            })}\n        </AnimatePresence>\n      </div>\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {frontItem ? `Item ${currentIndex + 1} of ${items.length}` : \"No items\"}\n      </div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/time-machine-stack/index.tsx","type":"registry:ui"}],"name":"time-machine-stack","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Time Machine Stack","type":"registry:ui"}