{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-showcase",
  "title": "App Showcase",
  "description": "A realistic iPhone or Android device frame that shows your app on screen, with scroll, loop, carousel, and cluster interactions.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/app-showcase/app-showcase.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type ShowcaseDevice = \"iphone\" | \"android\";\nexport type ShowcaseMode = \"scroll\" | \"loop\" | \"carousel\" | \"cluster\";\nexport type ShowcaseFrameColor = \"black\" | \"silver\" | \"gold\";\n\nexport type AppShowcaseProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  /** Device frame to render. */\n  device?: ShowcaseDevice;\n  /** Interaction mode — the \"wow\" behavior of the screen. */\n  mode?: ShowcaseMode;\n  /** Single-screen source (`scroll` / `loop` modes). A tall screenshot pans best. */\n  src?: string;\n  /** Single-screen video source (`scroll` / `loop`). Rendered over the screen. */\n  videoSrc?: string;\n  /** Multiple screens for `carousel` / `cluster` modes. */\n  screens?: string[];\n  /** Bezel finish. */\n  frameColor?: ShowcaseFrameColor;\n  /** Autoplay the carousel / start the loop (ignored under reduced motion). */\n  autoplay?: boolean;\n  /** Carousel autoplay interval / loop pass, in seconds. */\n  interval?: number;\n  /** Frame width in px. Height derives from the device aspect ratio. */\n  width?: number;\n  /** Accessible label for the screen media. */\n  alt?: string;\n};\n\n// SPRING.smooth — surfaces / shared-layout feel (matches the rest of GodUI).\nconst SPRING = { stiffness: 320, damping: 32, mass: 0.9 } as const;\n\n// Device geometry as ratios of the frame width, so the phone looks identical at\n// any `width` (fixed rem radii/notch would distort at small sizes).\nconst DEVICE: Record<\n  ShowcaseDevice,\n  { aspect: string; shellR: number; padR: number; screenR: number }\n> = {\n  iphone: {\n    aspect: \"aspect-[433/882]\",\n    shellR: 0.17,\n    padR: 0.035,\n    screenR: 0.13,\n  },\n  android: {\n    aspect: \"aspect-[9/19.5]\",\n    shellR: 0.11,\n    padR: 0.028,\n    screenR: 0.085,\n  },\n};\n\nconst FRAME_SHELL: Record<ShowcaseFrameColor, string> = {\n  black: \"bg-neutral-900 ring-1 ring-neutral-700/60\",\n  silver: \"bg-neutral-300 ring-1 ring-neutral-100\",\n  gold: \"bg-[#e6cfa6] ring-1 ring-[#f3e6cf]\",\n};\n\n/** Renders an image or a video that fills the screen area. */\nfunction ScreenMedia({\n  src,\n  videoSrc,\n  alt,\n  className,\n}: {\n  src?: string;\n  videoSrc?: string;\n  alt?: string;\n  className?: string;\n}) {\n  if (videoSrc) {\n    return (\n      // Plain DOM <video> (no SVG foreignObject) sidesteps the Safari/iOS mask bug.\n      <video\n        className={`size-full object-cover ${className ?? \"\"}`}\n        src={videoSrc}\n        autoPlay\n        muted\n        loop\n        playsInline\n      />\n    );\n  }\n  if (src) {\n    return (\n      <img\n        className={`size-full object-cover ${className ?? \"\"}`}\n        src={src}\n        alt={alt ?? \"\"}\n        draggable={false}\n      />\n    );\n  }\n  return <div className={`size-full bg-muted ${className ?? \"\"}`} />;\n}\n\n/** Static device shell with the notch / hole-punch and side buttons. */\nconst PhoneFrame = React.forwardRef<\n  HTMLDivElement,\n  {\n    device: ShowcaseDevice;\n    frameColor: ShowcaseFrameColor;\n    width?: number;\n    className?: string;\n    style?: React.CSSProperties;\n    children: React.ReactNode;\n  }\n>(({ device, frameColor, width, className, style, children }, ref) => {\n  const geo = DEVICE[device];\n  const w = width ?? 300;\n  // All radii / padding / notch derive from the width → identical at every size.\n  const pad = w * geo.padR;\n  const shellRadius = w * geo.shellR;\n  const screenRadius = w * geo.screenR;\n  const notchW = w * 0.3;\n  const notchH = w * 0.085;\n  const notchTop = w * 0.025;\n  const holeSize = w * 0.03;\n  return (\n    <div\n      ref={ref}\n      className={`relative ${geo.aspect} ${FRAME_SHELL[frameColor]} shadow-2xl ${className ?? \"\"}`}\n      style={{\n        width: `${w}px`,\n        padding: `${pad}px`,\n        borderRadius: `${shellRadius}px`,\n        ...style,\n      }}\n    >\n      {/* Side buttons */}\n      <span\n        aria-hidden\n        className=\"absolute -left-[2px] top-[22%] h-[7%] w-[3px] rounded-l bg-neutral-700\"\n      />\n      <span\n        aria-hidden\n        className=\"absolute -left-[2px] top-[33%] h-[10%] w-[3px] rounded-l bg-neutral-700\"\n      />\n      <span\n        aria-hidden\n        className=\"absolute -right-[2px] top-[26%] h-[12%] w-[3px] rounded-r bg-neutral-700\"\n      />\n      {/* Screen */}\n      <div\n        className=\"relative size-full overflow-hidden bg-black\"\n        style={{ borderRadius: `${screenRadius}px` }}\n      >\n        {children}\n        {/* Notch / camera */}\n        {device === \"iphone\" ? (\n          <span\n            aria-hidden\n            className=\"absolute left-1/2 z-raised -translate-x-1/2 rounded-full bg-black\"\n            style={{\n              top: `${notchTop}px`,\n              width: `${notchW}px`,\n              height: `${notchH}px`,\n            }}\n          />\n        ) : (\n          <span\n            aria-hidden\n            className=\"absolute left-1/2 z-raised -translate-x-1/2 rounded-full bg-black ring-2 ring-neutral-800\"\n            style={{\n              top: `${notchTop}px`,\n              width: `${holeSize}px`,\n              height: `${holeSize}px`,\n            }}\n          />\n        )}\n      </div>\n    </div>\n  );\n});\nPhoneFrame.displayName = \"PhoneFrame\";\n\n/** `scroll` mode: phone floats in on view and the screen pans with page scroll. */\nfunction ScrollShowcase({\n  device,\n  frameColor,\n  width,\n  src,\n  videoSrc,\n  alt,\n  reduce,\n}: {\n  device: ShowcaseDevice;\n  frameColor: ShowcaseFrameColor;\n  width?: number;\n  src?: string;\n  videoSrc?: string;\n  alt?: string;\n  reduce: boolean;\n}) {\n  const sectionRef = React.useRef<HTMLDivElement>(null);\n  const { scrollYProgress } = useScroll({\n    target: sectionRef,\n    offset: [\"start end\", \"end start\"],\n  });\n  // Pan the tall screenshot as the phone travels through the viewport.\n  const y = useSpring(\n    useTransform(scrollYProgress, [0, 1], [\"0%\", \"-45%\"]),\n    SPRING,\n  );\n\n  if (reduce) {\n    return (\n      <div ref={sectionRef}>\n        <PhoneFrame device={device} frameColor={frameColor} width={width}>\n          <ScreenMedia src={src} videoSrc={videoSrc} alt={alt} />\n        </PhoneFrame>\n      </div>\n    );\n  }\n\n  return (\n    <motion.div\n      ref={sectionRef}\n      initial={{ opacity: 0, y: 48, rotateX: 10 }}\n      whileInView={{ opacity: 1, y: 0, rotateX: 0 }}\n      viewport={{ once: true, amount: 0.3 }}\n      transition={{ type: \"spring\", ...SPRING }}\n      className=\"[transform-style:preserve-3d] [perspective:1200px]\"\n    >\n      <PhoneFrame device={device} frameColor={frameColor} width={width}>\n        {videoSrc ? (\n          <ScreenMedia videoSrc={videoSrc} alt={alt} />\n        ) : (\n          // Tall image (200% high) panned by scroll.\n          <motion.img\n            src={src}\n            alt={alt ?? \"\"}\n            draggable={false}\n            style={{ y }}\n            className=\"absolute inset-x-0 top-0 h-[200%] w-full object-cover\"\n          />\n        )}\n      </PhoneFrame>\n    </motion.div>\n  );\n}\n\n/** `loop` mode: the screen auto-scrolls on a gentle infinite loop while in view. */\nfunction LoopShowcase({\n  device,\n  frameColor,\n  width,\n  src,\n  videoSrc,\n  alt,\n  autoplay,\n  interval,\n  reduce,\n}: {\n  device: ShowcaseDevice;\n  frameColor: ShowcaseFrameColor;\n  width?: number;\n  src?: string;\n  videoSrc?: string;\n  alt?: string;\n  autoplay: boolean;\n  interval: number;\n  reduce: boolean;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [inView, setInView] = React.useState(false);\n\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const io = new IntersectionObserver(\n      ([entry]) => setInView(entry.isIntersecting),\n      { threshold: 0.2 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  const running = autoplay && inView && !reduce && !videoSrc;\n\n  return (\n    <PhoneFrame ref={ref} device={device} frameColor={frameColor} width={width}>\n      {videoSrc ? (\n        <ScreenMedia videoSrc={videoSrc} alt={alt} />\n      ) : (\n        <div className=\"absolute inset-0 overflow-hidden\">\n          <div\n            className={`flex flex-col ${running ? \"animate-app-showcase-scroll\" : \"\"} motion-reduce:animate-none`}\n            style={{\n              willChange: \"transform\",\n              [\"--as-loop\" as string]: `${interval * 6}s`,\n            }}\n          >\n            <img\n              src={src}\n              alt={alt ?? \"\"}\n              draggable={false}\n              className=\"w-full\"\n            />\n            {/* Duplicate for a seamless wrap. */}\n            <img\n              src={src}\n              alt=\"\"\n              aria-hidden\n              draggable={false}\n              className=\"w-full\"\n            />\n          </div>\n        </div>\n      )}\n    </PhoneFrame>\n  );\n}\n\n/** `carousel` mode: cross-fade through screens with dot controls. */\nfunction CarouselShowcase({\n  device,\n  frameColor,\n  width,\n  screens,\n  alt,\n  autoplay,\n  interval,\n  reduce,\n}: {\n  device: ShowcaseDevice;\n  frameColor: ShowcaseFrameColor;\n  width?: number;\n  screens: string[];\n  alt?: string;\n  autoplay: boolean;\n  interval: number;\n  reduce: boolean;\n}) {\n  const [index, setIndex] = React.useState(0);\n  const count = screens.length;\n\n  React.useEffect(() => {\n    if (!autoplay || reduce || count < 2) return;\n    const id = window.setInterval(\n      () => setIndex((i) => (i + 1) % count),\n      interval * 1000,\n    );\n    return () => window.clearInterval(id);\n  }, [autoplay, reduce, count, interval]);\n\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\n      <PhoneFrame device={device} frameColor={frameColor} width={width}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.img\n            key={index}\n            src={screens[index]}\n            alt={alt ?? `Screen ${index + 1}`}\n            draggable={false}\n            initial={reduce ? false : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={reduce ? undefined : { opacity: 0 }}\n            transition={{\n              duration: reduce ? 0 : 0.3,\n              ease: [0.22, 1, 0.36, 1],\n            }}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n        </AnimatePresence>\n      </PhoneFrame>\n      {count > 1 ? (\n        <div className=\"flex items-center gap-2\" role=\"tablist\">\n          {screens.map((s, i) => (\n            <button\n              key={s}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={i === index}\n              aria-label={`Go to screen ${i + 1}`}\n              onClick={() => setIndex(i)}\n              className={`h-2 rounded-full [transition:width_200ms_ease,background-color_200ms_ease] ${\n                i === index\n                  ? \"w-6 bg-primary\"\n                  : \"w-2 bg-[var(--muted-foreground)]/30 hover:bg-[var(--muted-foreground)]/50\"\n              }`}\n            />\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\ntype ClusterLayout = {\n  depth: number;\n  transform: string;\n  z: number;\n};\n\n// Depth per phone: back phones move more (parallax), front least.\nconst CLUSTER_LAYOUT: ClusterLayout[] = [\n  { depth: 26, transform: \"translateX(-58%) rotate(-8deg) scale(0.82)\", z: 0 },\n  { depth: 0, transform: \"translateX(0%) rotate(0deg) scale(1)\", z: 2 },\n  { depth: 26, transform: \"translateX(58%) rotate(8deg) scale(0.82)\", z: 0 },\n];\n\n/** One phone in the cluster — owns its parallax transforms (hooks live here). */\nfunction ClusterPhone({\n  layout,\n  sx,\n  sy,\n  reduce,\n  device,\n  frameColor,\n  width,\n  src,\n  alt,\n}: {\n  layout: ClusterLayout;\n  sx: ReturnType<typeof useSpring>;\n  sy: ReturnType<typeof useSpring>;\n  reduce: boolean;\n  device: ShowcaseDevice;\n  frameColor: ShowcaseFrameColor;\n  width?: number;\n  src: string;\n  alt?: string;\n}) {\n  const x = useTransform(sx, [-0.5, 0.5], [-layout.depth, layout.depth]);\n  const y = useTransform(\n    sy,\n    [-0.5, 0.5],\n    [-layout.depth / 2, layout.depth / 2],\n  );\n  return (\n    // Outer div owns the static cluster placement; inner motion.div the parallax.\n    <div\n      className=\"absolute\"\n      style={{ transform: layout.transform, zIndex: layout.z }}\n    >\n      <motion.div style={reduce ? undefined : { x, y }}>\n        <PhoneFrame device={device} frameColor={frameColor} width={width}>\n          <ScreenMedia src={src} alt={alt} />\n        </PhoneFrame>\n      </motion.div>\n    </div>\n  );\n}\n\n/** `cluster` mode: 2–3 phones at depth with pointer parallax. */\nfunction ClusterShowcase({\n  device,\n  frameColor,\n  width,\n  screens,\n  alt,\n  reduce,\n}: {\n  device: ShowcaseDevice;\n  frameColor: ShowcaseFrameColor;\n  width?: number;\n  screens: string[];\n  alt?: string;\n  reduce: boolean;\n}) {\n  const phones = screens.slice(0, 3);\n  const ref = React.useRef<HTMLDivElement>(null);\n  const px = useMotionValue(0);\n  const py = useMotionValue(0);\n  const sx = useSpring(px, SPRING);\n  const sy = useSpring(py, SPRING);\n\n  const handleMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (reduce) return;\n    const el = ref.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    px.set((e.clientX - rect.left) / rect.width - 0.5);\n    py.set((e.clientY - rect.top) / rect.height - 0.5);\n  };\n  const handleLeave = () => {\n    px.set(0);\n    py.set(0);\n  };\n\n  return (\n    <div\n      ref={ref}\n      onPointerMove={handleMove}\n      onPointerLeave={handleLeave}\n      className=\"relative flex items-center justify-center [perspective:1400px]\"\n      style={{ minHeight: width ? `${width * 2.0}px` : \"480px\" }}\n    >\n      {phones.map((src, i) => (\n        <ClusterPhone\n          key={src}\n          layout={CLUSTER_LAYOUT[i] ?? CLUSTER_LAYOUT[1]}\n          sx={sx}\n          sy={sy}\n          reduce={reduce}\n          device={device}\n          frameColor={frameColor}\n          width={width}\n          src={src}\n          alt={alt}\n        />\n      ))}\n    </div>\n  );\n}\n\nconst AppShowcase = React.forwardRef<HTMLDivElement, AppShowcaseProps>(\n  (\n    {\n      device = \"iphone\",\n      mode = \"scroll\",\n      src,\n      videoSrc,\n      screens,\n      frameColor = \"black\",\n      autoplay = true,\n      interval = 4,\n      width = 300,\n      alt,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduce = useReducedMotion() ?? false;\n    const list = screens ?? (src ? [src] : []);\n\n    let body: React.ReactNode;\n    if (mode === \"loop\") {\n      body = (\n        <LoopShowcase\n          device={device}\n          frameColor={frameColor}\n          width={width}\n          src={src ?? list[0]}\n          videoSrc={videoSrc}\n          alt={alt}\n          autoplay={autoplay}\n          interval={interval}\n          reduce={reduce}\n        />\n      );\n    } else if (mode === \"carousel\") {\n      body = (\n        <CarouselShowcase\n          device={device}\n          frameColor={frameColor}\n          width={width}\n          screens={list}\n          alt={alt}\n          autoplay={autoplay}\n          interval={interval}\n          reduce={reduce}\n        />\n      );\n    } else if (mode === \"cluster\") {\n      body = (\n        <ClusterShowcase\n          device={device}\n          frameColor={frameColor}\n          width={width}\n          screens={list}\n          alt={alt}\n          reduce={reduce}\n        />\n      );\n    } else {\n      body = (\n        <ScrollShowcase\n          device={device}\n          frameColor={frameColor}\n          width={width}\n          src={src ?? list[0]}\n          videoSrc={videoSrc}\n          alt={alt}\n          reduce={reduce}\n        />\n      );\n    }\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"app-showcase\"\n        data-device={device}\n        data-mode={mode}\n        className={`flex items-center justify-center ${className ?? \"\"}`}\n        {...props}\n      >\n        {body}\n      </div>\n    );\n  },\n);\nAppShowcase.displayName = \"AppShowcase\";\n\nexport { AppShowcase };\n",
      "type": "registry:ui",
      "target": "components/godui/app-showcase.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-app-showcase-scroll": "app-showcase-scroll var(--as-loop, 24s) linear infinite"
    }
  },
  "css": {
    "@keyframes app-showcase-scroll": {
      "from": {
        "transform": "translateY(0)"
      },
      "to": {
        "transform": "translateY(-50%)"
      }
    }
  },
  "type": "registry:ui"
}
