{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-viewer",
  "type": "registry:ui",
  "title": "Floating Viewer",
  "description": "An in-page media surface that automatically or manually detaches, stays within viewport bounds, snaps to a corner, and restores without remounting its content.",
  "files": [
    {
      "path": "registry/sonaui/floating-viewer/floating-viewer.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type HTMLAttributes,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport type FloatingViewerCorner =\n  | \"top-left\"\n  | \"top-right\"\n  | \"bottom-left\"\n  | \"bottom-right\";\n\nexport interface FloatingViewerProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Media or reference content that remains mounted while the viewer moves. */\n  children: ReactNode;\n  /** Accessible name announced for the floating viewer. @default \"Floating media viewer\" */\n  ariaLabel?: string;\n  /** CSS aspect-ratio value used by both viewer states. @default \"16 / 9\" */\n  aspectRatio?: string;\n  /** Width of the floating surface in pixels. @default 320 */\n  floatingWidth?: number;\n  /** Gap between the floating surface and viewport edges in pixels. @default 16 */\n  viewportPadding?: number;\n  /** Corner used when the viewer first detaches. @default \"bottom-right\" */\n  defaultCorner?: FloatingViewerCorner;\n  /** Automatically detach when the inline viewer leaves the viewport. @default true */\n  floatOnExit?: boolean;\n  /** Shows the built-in Float button while the viewer is inline. @default true */\n  showFloatControl?: boolean;\n  /** Called whenever the viewer enters or leaves its floating state. @default undefined */\n  onFloatingChange?: (floating: boolean) => void;\n  /** Additional CSS classes for the media surface. @default undefined */\n  surfaceClassName?: string;\n}\n\ntype Size = { width: number; height: number };\ntype FloatingReason = \"auto\" | \"manual\" | null;\n\nconst corners: FloatingViewerCorner[] = [\n  \"top-left\",\n  \"top-right\",\n  \"bottom-right\",\n  \"bottom-left\",\n];\n\nfunction getCornerPosition(\n  corner: FloatingViewerCorner,\n  viewport: Size,\n  viewer: Size,\n  padding: number,\n) {\n  return {\n    x: corner.endsWith(\"right\")\n      ? Math.max(padding, viewport.width - viewer.width - padding)\n      : padding,\n    y: corner.startsWith(\"bottom\")\n      ? Math.max(padding, viewport.height - viewer.height - padding)\n      : padding,\n  };\n}\n\nexport default function FloatingViewer({\n  children,\n  ariaLabel = \"Floating media viewer\",\n  aspectRatio = \"16 / 9\",\n  floatingWidth = 320,\n  viewportPadding = 16,\n  defaultCorner = \"bottom-right\",\n  floatOnExit = true,\n  showFloatControl = true,\n  onFloatingChange,\n  className,\n  surfaceClassName,\n  ...props\n}: FloatingViewerProps) {\n  const anchorRef = useRef<HTMLDivElement>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const [floatingReason, setFloatingReason] = useState<FloatingReason>(null);\n  const [corner, setCorner] = useState(defaultCorner);\n  const [dismissedUntilVisible, setDismissedUntilVisible] = useState(false);\n  const [viewport, setViewport] = useState<Size>({ width: 0, height: 0 });\n  const [viewer, setViewer] = useState<Size>({ width: 0, height: 0 });\n\n  const floating = floatingReason !== null;\n\n  const setFloatingState = useCallback(\n    (reason: FloatingReason) => {\n      setFloatingReason((current) => {\n        if ((current !== null) !== (reason !== null)) {\n          onFloatingChange?.(reason !== null);\n        }\n        return reason;\n      });\n    },\n    [onFloatingChange],\n  );\n\n  const moveToCorner = useCallback(\n    (nextCorner: FloatingViewerCorner, immediate = false) => {\n      const target = getCornerPosition(\n        nextCorner,\n        viewport,\n        viewer,\n        viewportPadding,\n      );\n      setCorner(nextCorner);\n\n      if (immediate || shouldReduceMotion) {\n        x.set(target.x);\n        y.set(target.y);\n        return;\n      }\n\n      const transition = { type: \"spring\" as const, bounce: 0, duration: 0.4 };\n      animate(x, target.x, transition);\n      animate(y, target.y, transition);\n    },\n    [shouldReduceMotion, viewport, viewer, viewportPadding, x, y],\n  );\n\n  useEffect(() => {\n    const updateViewport = () =>\n      setViewport({ width: window.innerWidth, height: window.innerHeight });\n    updateViewport();\n    window.addEventListener(\"resize\", updateViewport);\n    return () => window.removeEventListener(\"resize\", updateViewport);\n  }, []);\n\n  useEffect(() => {\n    const surface = surfaceRef.current;\n    if (!surface) return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const borderSize = entry.borderBoxSize[0];\n      setViewer({\n        width: borderSize?.inlineSize ?? entry.contentRect.width,\n        height: borderSize?.blockSize ?? entry.contentRect.height,\n      });\n    });\n    observer.observe(surface);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (!floating || viewport.width === 0 || viewer.width === 0) return;\n    moveToCorner(corner);\n  }, [corner, floating, moveToCorner, viewer.width, viewport.width]);\n\n  useEffect(() => {\n    const anchor = anchorRef.current;\n    if (!anchor || !floatOnExit) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry) return;\n        if (entry.isIntersecting) {\n          setDismissedUntilVisible(false);\n          if (floatingReason === \"auto\") setFloatingState(null);\n        } else if (!dismissedUntilVisible && floatingReason === null) {\n          setFloatingState(\"auto\");\n        }\n      },\n      { threshold: 0.35 },\n    );\n    observer.observe(anchor);\n    return () => observer.disconnect();\n  }, [dismissedUntilVisible, floatOnExit, floatingReason, setFloatingState]);\n\n  const restore = () => {\n    anchorRef.current?.scrollIntoView({\n      behavior: shouldReduceMotion ? \"auto\" : \"smooth\",\n      block: \"center\",\n    });\n    setFloatingState(null);\n  };\n\n  const closeFloating = () => {\n    setDismissedUntilVisible(true);\n    setFloatingState(null);\n  };\n\n  const cycleCorner = () => {\n    const currentIndex = corners.indexOf(corner);\n    moveToCorner(corners[(currentIndex + 1) % corners.length]);\n  };\n\n  const surface = (\n    <motion.div\n      aria-label={floating ? ariaLabel : undefined}\n      className={cn(\n        \"group/viewer relative isolate overflow-hidden rounded-2xl bg-black\",\n        !floating && \"absolute inset-0\",\n        floating &&\n          \"fixed top-0 left-0 z-50 cursor-grab touch-none shadow-black/20 shadow-xl active:cursor-grabbing\",\n        surfaceClassName,\n      )}\n      drag={floating}\n      dragConstraints={\n        floating\n          ? {\n              left: viewportPadding,\n              right: Math.max(\n                viewportPadding,\n                viewport.width - viewer.width - viewportPadding,\n              ),\n              top: viewportPadding,\n              bottom: Math.max(\n                viewportPadding,\n                viewport.height - viewer.height - viewportPadding,\n              ),\n            }\n          : undefined\n      }\n      dragElastic={0.08}\n      dragMomentum={false}\n      layout={!shouldReduceMotion}\n      onDragEnd={(_, info) => {\n        const nextCorner: FloatingViewerCorner = `${\n          info.point.y < viewport.height / 2 ? \"top\" : \"bottom\"\n        }-${info.point.x < viewport.width / 2 ? \"left\" : \"right\"}`;\n        moveToCorner(nextCorner);\n      }}\n      ref={surfaceRef}\n      role={floating ? \"region\" : undefined}\n      style={{\n        aspectRatio,\n        width: floating\n          ? Math.min(floatingWidth, viewport.width - viewportPadding * 2)\n          : \"100%\",\n        x: floating ? x : 0,\n        y: floating ? y : 0,\n      }}\n      transition={{ type: \"spring\", bounce: 0, duration: 0.4 }}\n    >\n      <div className=\"size-full\">{children}</div>\n\n      {floating ? (\n        <div className=\"absolute top-2 right-2 flex items-center gap-1 rounded-full bg-black/60 p-1 text-white backdrop-blur-md\">\n          <ViewerButton\n            label={`Move viewer from ${corner}`}\n            onClick={cycleCorner}\n          >\n            <path d=\"M8 3H3v5M16 3h5v5M8 21H3v-5m13 5h5v-5\" />\n          </ViewerButton>\n          <ViewerButton label=\"Restore viewer to the page\" onClick={restore}>\n            <path d=\"M8 8h8v8H8zM5 12H3V5h7v2m9 5h2v7h-7v-2\" />\n          </ViewerButton>\n          <ViewerButton label=\"Close floating viewer\" onClick={closeFloating}>\n            <path d=\"m7 7 10 10M17 7 7 17\" />\n          </ViewerButton>\n        </div>\n      ) : showFloatControl ? (\n        <button\n          className=\"absolute top-3 right-3 inline-flex cursor-pointer items-center gap-2 rounded-full bg-black/60 px-3 py-2 font-medium text-white text-xs opacity-0 backdrop-blur-md transition-[opacity,background-color] duration-150 group-hover/viewer:opacity-100 hover:bg-black/75 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80\"\n          onClick={() => setFloatingState(\"manual\")}\n          type=\"button\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            className=\"size-3.5\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path\n              d=\"M14 5h5v5m0-5-6 6M10 19H5v-5m0 5 6-6\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.8\"\n            />\n          </svg>\n          Float\n        </button>\n      ) : null}\n    </motion.div>\n  );\n\n  return (\n    <div className={cn(\"relative w-full\", className)} {...props}>\n      <div aria-hidden=\"true\" ref={anchorRef} style={{ aspectRatio }}>\n        {floating ? (\n          <div className=\"size-full rounded-2xl bg-muted/45\" />\n        ) : null}\n      </div>\n      {surface}\n    </div>\n  );\n}\n\nfunction ViewerButton({\n  children,\n  label,\n  onClick,\n}: {\n  children: ReactNode;\n  label: string;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      aria-label={label}\n      className=\"inline-flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors duration-150 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80\"\n      onClick={onClick}\n      onPointerDown={(event) => event.stopPropagation()}\n      type=\"button\"\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-4\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n      >\n        <g\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.8\"\n        >\n          {children}\n        </g>\n      </svg>\n    </button>\n  );\n}\n",
      "target": "components/ui/floating-viewer/floating-viewer.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}