{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","minimal"],"dependencies":["lucide-react","motion"],"description":"iOS picture-in-picture — flick the call window and velocity picks its corner; throw it off an edge to tuck it behind a peek tab.","devDependencies":[],"files":[{"content":"import type { ReactNode } from \"react\";\n\n// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.\nexport const SCREEN_WIDTH = 330;\nexport const SCREEN_HEIGHT = 716;\n\ninterface Clay {\n  base: string;\n  deep: string;\n  light: string;\n  /** RGB triplet for inner shading, tinted so each tone reads as one material. */\n  rim: string;\n}\n\n// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.\nconst TONES = {\n  espresso: { base: \"#3a2b24\", deep: \"#1f1612\", light: \"#5a4436\", rim: \"32 20 14\" },\n  graphite: { base: \"#303036\", deep: \"#18181c\", light: \"#4a4a52\", rim: \"10 10 14\" },\n  moss: { base: \"#2f3a2c\", deep: \"#171d15\", light: \"#4a5a44\", rim: \"12 18 10\" },\n  ochre: { base: \"#4a3c20\", deep: \"#261e0e\", light: \"#6e5a30\", rim: \"30 22 6\" },\n  plum: { base: \"#3c2a3a\", deep: \"#1e141d\", light: \"#5c4058\", rim: \"26 12 24\" },\n  slate: { base: \"#2a3448\", deep: \"#141a26\", light: \"#435270\", rim: \"10 16 30\" },\n  teal: { base: \"#233a3a\", deep: \"#101d1d\", light: \"#38585a\", rim: \"6 22 22\" },\n  terracotta: { base: \"#4a2c22\", deep: \"#26150f\", light: \"#6c4232\", rim: \"34 14 8\" },\n} satisfies Record<string, Clay>;\n\nexport type ClayTone = keyof typeof TONES;\n\nconst shellShadow = (rim: string) =>\n  [\n    \"0 60px 100px -40px rgb(0 0 0 / 0.85)\",\n    \"0 26px 40px -24px rgb(0 0 0 / 0.7)\",\n    \"inset 0 6px 6px rgb(255 255 255 / 0.16)\",\n    `inset 0 -14px 22px rgb(${rim} / 0.75)`,\n    \"inset 12px 0 18px -12px rgb(255 255 255 / 0.1)\",\n    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,\n  ].join(\", \");\n\nconst Nub = ({ className, clay }: { className: string; clay: Clay }) => (\n  <span\n    aria-hidden=\"true\"\n    className={`absolute w-[9px] rounded-full ${className}`}\n    style={{\n      background: clay.base,\n      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,\n    }}\n  />\n);\n\ninterface PhoneFrameProps {\n  children: ReactNode;\n  /** Classes for the screen: background, text color, pointer handling. */\n  className?: string;\n  /** Hide the Dynamic Island when the screen draws its own status area. */\n  island?: boolean;\n  tone?: ClayTone;\n}\n\nexport const PhoneFrame = ({\n  children,\n  className,\n  island = true,\n  tone = \"graphite\",\n}: PhoneFrameProps) => {\n  const clay = TONES[tone];\n  return (\n    <div\n      className=\"relative shrink-0 rounded-[66px] p-[16px]\"\n      style={{\n        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,\n        boxShadow: shellShadow(clay.rim),\n      }}\n    >\n      <Nub clay={clay} className=\"top-[132px] -left-[6px] h-[36px]\" />\n      <Nub clay={clay} className=\"top-[188px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[262px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[206px] -right-[6px] h-[92px]\" />\n      <div\n        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? \"bg-black\"}`}\n        style={{\n          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,\n          height: SCREEN_HEIGHT,\n          width: SCREEN_WIDTH,\n        }}\n      >\n        {children}\n        {island && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/phone-frame.tsx","target":"uicapsule/pip-window/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { ChevronLeft, ChevronRight, Mic, Video } from \"lucide-react\";\nimport { motion, useAnimation, useReducedMotion } from \"motion/react\";\n\nimport { PhoneFrame, SCREEN_HEIGHT, SCREEN_WIDTH } from \"./phone-frame\";\n\nconst PIP_WIDTH = 150;\nconst PIP_HEIGHT = 100;\nconst PADDING = 14;\nconst PEEK = 26;\n\ntype Tucked = \"left\" | \"right\" | null;\n\n// Top corners clear the Dynamic Island.\nconst TOP_Y = PADDING + 46;\n// Bottom corners sit above the call bar so the window never covers its controls.\nconst BOTTOM_Y = SCREEN_HEIGHT - PIP_HEIGHT - 96;\n\nconst HOME = { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: BOTTOM_Y };\n\nconst CORNERS = [\n  { x: PADDING, y: TOP_Y },\n  { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: TOP_Y },\n  { x: PADDING, y: BOTTOM_Y },\n  HOME,\n];\n\nconst clampY = (value: number) => Math.min(BOTTOM_Y, Math.max(TOP_Y, value));\n\nconst NOTES = [\n  [\"Capsule roadmap\", \"45 components · 12 shipped\"],\n  [\"PiP physics\", \"Flick it — velocity picks the corner\"],\n  [\"Edge tuck\", \"Throw it off the side, tap the tab to bring it back\"],\n  [\"Cover videos\", \"record restart re-arms capture on the sized tab\"],\n  [\"Motion notes\", \"One spring config per size change\"],\n];\n\nexport const PipWindow = () => {\n  const controls = useAnimation();\n  const positionRef = useRef({ ...HOME });\n  const [tucked, setTucked] = useState<Tucked>(null);\n  const reduced = useReducedMotion() ?? false;\n  const spring = (bounce: number, duration: number) =>\n    reduced ? { duration: 0 } : { bounce, duration, type: \"spring\" as const };\n\n  const settle = (offsetX: number, offsetY: number, velocityX: number, velocityY: number) => {\n    const { current } = positionRef;\n    const projectedX = current.x + offsetX + velocityX * 0.18;\n    const projectedY = current.y + offsetY + velocityY * 0.18;\n\n    // A hard sideways throw tucks the window off-screen.\n    if (projectedX < -PIP_WIDTH * 0.35 || (velocityX < -900 && projectedX < PADDING)) {\n      positionRef.current = { x: -(PIP_WIDTH - PEEK), y: clampY(projectedY) };\n      setTucked(\"left\");\n      void controls.start({\n        ...positionRef.current,\n        transition: spring(0.24, 0.5),\n      });\n      return;\n    }\n    if (\n      projectedX > SCREEN_WIDTH - PIP_WIDTH * 0.65 ||\n      (velocityX > 900 && projectedX > SCREEN_WIDTH - PIP_WIDTH - PADDING)\n    ) {\n      positionRef.current = { x: SCREEN_WIDTH - PEEK, y: clampY(projectedY) };\n      setTucked(\"right\");\n      void controls.start({\n        ...positionRef.current,\n        transition: spring(0.24, 0.5),\n      });\n      return;\n    }\n\n    let nearest = HOME;\n    let best = Number.POSITIVE_INFINITY;\n    for (const corner of CORNERS) {\n      const distance = (corner.x - projectedX) ** 2 + (corner.y - projectedY) ** 2;\n      if (distance < best) {\n        best = distance;\n        nearest = corner;\n      }\n    }\n    positionRef.current = { ...nearest };\n    setTucked(null);\n    void controls.start({\n      ...nearest,\n      transition: spring(0.32, 0.55),\n    });\n  };\n\n  const untuck = () => {\n    const side = tucked;\n    if (!side) {\n      return;\n    }\n    const target =\n      side === \"left\"\n        ? { x: PADDING, y: positionRef.current.y }\n        : { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: positionRef.current.y };\n    positionRef.current = target;\n    setTucked(null);\n    void controls.start({\n      ...target,\n      transition: spring(0.35, 0.5),\n    });\n  };\n\n  return (\n    <PhoneFrame className=\"bg-[#0b1516]\" tone=\"teal\">\n      <div className=\"px-5 pt-[60px]\">\n        <p className=\"text-[24px] font-bold text-white\">Notes</p>\n        <div className=\"mt-4 space-y-2.5\">\n          {NOTES.map(([title, body]) => (\n            <div key={title} className=\"rounded-2xl bg-white/[0.05] p-4 ring-1 ring-white/[0.05]\">\n              <p className=\"text-[13px] font-semibold text-white/90\">{title}</p>\n              <p className=\"mt-0.5 text-[12px] text-white/40\">{body}</p>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <motion.div\n        drag\n        dragMomentum={false}\n        dragElastic={0.12}\n        initial={HOME}\n        animate={controls}\n        onDragEnd={(_, info) =>\n          settle(info.offset.x, info.offset.y, info.velocity.x, info.velocity.y)\n        }\n        onClick={untuck}\n        style={{ height: PIP_HEIGHT, width: PIP_WIDTH }}\n        className=\"absolute top-0 left-0 cursor-grab overflow-hidden rounded-2xl shadow-2xl shadow-black/70 ring-1 ring-white/20 active:cursor-grabbing\"\n      >\n        <div className=\"absolute inset-0 bg-gradient-to-br from-[#3f6a68] via-[#20393b] to-[#112022]\" />\n        <motion.div\n          aria-hidden\n          className=\"absolute -top-6 left-8 size-24 rounded-full bg-[#8cc2ba]/30 blur-xl\"\n          animate={reduced ? undefined : { x: [0, 8, -4, 0], y: [0, 4, -2, 0] }}\n          transition={{ duration: 6, ease: \"easeInOut\", repeat: Infinity }}\n        />\n        <div className=\"absolute top-1/2 left-1/2 grid size-12 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-gradient-to-b from-[#f0a58f] to-[#c76b52] text-[15px] font-semibold text-white\">\n          NV\n        </div>\n        <div className=\"absolute bottom-1.5 left-2 flex items-center gap-1 text-white/80\">\n          <Video className=\"size-3\" />\n          <span className=\"text-[10px] font-medium\">Nova</span>\n        </div>\n        <motion.div className=\"absolute right-2 bottom-1.5 flex items-end gap-[2px]\" aria-hidden>\n          {[0, 1, 2].map((bar) => (\n            <motion.span\n              key={bar}\n              className=\"w-[3px] rounded-full bg-emerald-400\"\n              animate={reduced ? { height: 6 + bar * 2 } : { height: [3, 9 + bar * 2, 4, 8, 3] }}\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { duration: 0.9 + bar * 0.2, ease: \"easeInOut\", repeat: Infinity }\n              }\n            />\n          ))}\n        </motion.div>\n\n        {tucked && (\n          <div\n            className={`absolute inset-y-0 flex items-center bg-black/30 ${\n              tucked === \"left\" ? \"right-0 pr-1\" : \"left-0 pl-1\"\n            }`}\n          >\n            {tucked === \"left\" ? (\n              <ChevronRight className=\"size-4 text-white/80\" />\n            ) : (\n              <ChevronLeft className=\"size-4 text-white/80\" />\n            )}\n          </div>\n        )}\n      </motion.div>\n\n      <p className=\"pointer-events-none absolute inset-x-0 bottom-6 text-center text-[11px] text-white/30\">\n        Flick the call window · throw it off an edge to tuck it\n      </p>\n\n      <div className=\"pointer-events-none absolute inset-x-16 bottom-12 flex items-center justify-center gap-4 rounded-full bg-white/[0.07] py-2.5 backdrop-blur-md ring-1 ring-white/10\">\n        <Mic className=\"size-4 text-white/70\" />\n        <Video className=\"size-4 text-white/70\" />\n        <span className=\"size-4 rounded-full bg-red-500\" />\n      </div>\n    </PhoneFrame>\n  );\n};\n","path":"/pip-window.tsx","target":"uicapsule/pip-window/pip-window.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport type { CSSProperties } from \"react\";\n\nimport { PipWindow } from \"./pip-window\";\n\n// Faint scanlines nod to the video call without competing with the floating window.\nconst backdrop: CSSProperties = {\n  background: [\n    \"repeating-linear-gradient(0deg, rgb(255 255 255 / 0.018) 0 1px, transparent 1px 4px)\",\n    \"radial-gradient(ellipse 60% 45% at 50% 100%, rgb(46 120 116 / 0.35), transparent 75%)\",\n    \"radial-gradient(ellipse 45% 55% at 50% 40%, rgb(30 78 78 / 0.4), transparent 70%)\",\n    \"linear-gradient(180deg, #0a1c1d 0%, #07191a 45%, #031011 100%)\",\n  ].join(\", \"),\n};\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden p-5\" style={backdrop}>\n    <PipWindow />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/pip-window/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/pip-window","name":"pip-window","registryDependencies":[],"title":"PiP Window","type":"registry:block"}