# PiP Window

> 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.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/pip-window).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/pip-window
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/pip-window-light-22018f1666fd9474.png
- Registry item: https://uicapsule.com/r/pip-window.json
- Categories: controls, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/pip-window.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `/phone-frame.tsx`

```tsx
import type { ReactNode } from "react";

// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.
export const SCREEN_WIDTH = 330;
export const SCREEN_HEIGHT = 716;

interface Clay {
  base: string;
  deep: string;
  light: string;
  /** RGB triplet for inner shading, tinted so each tone reads as one material. */
  rim: string;
}

// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.
const TONES = {
  espresso: { base: "#3a2b24", deep: "#1f1612", light: "#5a4436", rim: "32 20 14" },
  graphite: { base: "#303036", deep: "#18181c", light: "#4a4a52", rim: "10 10 14" },
  moss: { base: "#2f3a2c", deep: "#171d15", light: "#4a5a44", rim: "12 18 10" },
  ochre: { base: "#4a3c20", deep: "#261e0e", light: "#6e5a30", rim: "30 22 6" },
  plum: { base: "#3c2a3a", deep: "#1e141d", light: "#5c4058", rim: "26 12 24" },
  slate: { base: "#2a3448", deep: "#141a26", light: "#435270", rim: "10 16 30" },
  teal: { base: "#233a3a", deep: "#101d1d", light: "#38585a", rim: "6 22 22" },
  terracotta: { base: "#4a2c22", deep: "#26150f", light: "#6c4232", rim: "34 14 8" },
} satisfies Record<string, Clay>;

export type ClayTone = keyof typeof TONES;

const shellShadow = (rim: string) =>
  [
    "0 60px 100px -40px rgb(0 0 0 / 0.85)",
    "0 26px 40px -24px rgb(0 0 0 / 0.7)",
    "inset 0 6px 6px rgb(255 255 255 / 0.16)",
    `inset 0 -14px 22px rgb(${rim} / 0.75)`,
    "inset 12px 0 18px -12px rgb(255 255 255 / 0.1)",
    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,
  ].join(", ");

const Nub = ({ className, clay }: { className: string; clay: Clay }) => (
  <span
    aria-hidden="true"
    className={`absolute w-[9px] rounded-full ${className}`}
    style={{
      background: clay.base,
      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)`,
    }}
  />
);

interface PhoneFrameProps {
  children: ReactNode;
  /** Classes for the screen: background, text color, pointer handling. */
  className?: string;
  /** Hide the Dynamic Island when the screen draws its own status area. */
  island?: boolean;
  tone?: ClayTone;
}

export const PhoneFrame = ({
  children,
  className,
  island = true,
  tone = "graphite",
}: PhoneFrameProps) => {
  const clay = TONES[tone];
  return (
    <div
      className="relative shrink-0 rounded-[66px] p-[16px]"
      style={{
        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,
        boxShadow: shellShadow(clay.rim),
      }}
    >
      <Nub clay={clay} className="top-[132px] -left-[6px] h-[36px]" />
      <Nub clay={clay} className="top-[188px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[262px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[206px] -right-[6px] h-[92px]" />
      <div
        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? "bg-black"}`}
        style={{
          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)`,
          height: SCREEN_HEIGHT,
          width: SCREEN_WIDTH,
        }}
      >
        {children}
        {island && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black"
          />
        )}
      </div>
    </div>
  );
};
```

### `/pip-window.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { ChevronLeft, ChevronRight, Mic, Video } from "lucide-react";
import { motion, useAnimation, useReducedMotion } from "motion/react";

import { PhoneFrame, SCREEN_HEIGHT, SCREEN_WIDTH } from "./phone-frame";

const PIP_WIDTH = 150;
const PIP_HEIGHT = 100;
const PADDING = 14;
const PEEK = 26;

type Tucked = "left" | "right" | null;

// Top corners clear the Dynamic Island.
const TOP_Y = PADDING + 46;
// Bottom corners sit above the call bar so the window never covers its controls.
const BOTTOM_Y = SCREEN_HEIGHT - PIP_HEIGHT - 96;

const HOME = { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: BOTTOM_Y };

const CORNERS = [
  { x: PADDING, y: TOP_Y },
  { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: TOP_Y },
  { x: PADDING, y: BOTTOM_Y },
  HOME,
];

const clampY = (value: number) => Math.min(BOTTOM_Y, Math.max(TOP_Y, value));

const NOTES = [
  ["Capsule roadmap", "45 components · 12 shipped"],
  ["PiP physics", "Flick it — velocity picks the corner"],
  ["Edge tuck", "Throw it off the side, tap the tab to bring it back"],
  ["Cover videos", "record restart re-arms capture on the sized tab"],
  ["Motion notes", "One spring config per size change"],
];

export const PipWindow = () => {
  const controls = useAnimation();
  const positionRef = useRef({ ...HOME });
  const [tucked, setTucked] = useState<Tucked>(null);
  const reduced = useReducedMotion() ?? false;
  const spring = (bounce: number, duration: number) =>
    reduced ? { duration: 0 } : { bounce, duration, type: "spring" as const };

  const settle = (offsetX: number, offsetY: number, velocityX: number, velocityY: number) => {
    const { current } = positionRef;
    const projectedX = current.x + offsetX + velocityX * 0.18;
    const projectedY = current.y + offsetY + velocityY * 0.18;

    // A hard sideways throw tucks the window off-screen.
    if (projectedX < -PIP_WIDTH * 0.35 || (velocityX < -900 && projectedX < PADDING)) {
      positionRef.current = { x: -(PIP_WIDTH - PEEK), y: clampY(projectedY) };
      setTucked("left");
      void controls.start({
        ...positionRef.current,
        transition: spring(0.24, 0.5),
      });
      return;
    }
    if (
      projectedX > SCREEN_WIDTH - PIP_WIDTH * 0.65 ||
      (velocityX > 900 && projectedX > SCREEN_WIDTH - PIP_WIDTH - PADDING)
    ) {
      positionRef.current = { x: SCREEN_WIDTH - PEEK, y: clampY(projectedY) };
      setTucked("right");
      void controls.start({
        ...positionRef.current,
        transition: spring(0.24, 0.5),
      });
      return;
    }

    let nearest = HOME;
    let best = Number.POSITIVE_INFINITY;
    for (const corner of CORNERS) {
      const distance = (corner.x - projectedX) ** 2 + (corner.y - projectedY) ** 2;
      if (distance < best) {
        best = distance;
        nearest = corner;
      }
    }
    positionRef.current = { ...nearest };
    setTucked(null);
    void controls.start({
      ...nearest,
      transition: spring(0.32, 0.55),
    });
  };

  const untuck = () => {
    const side = tucked;
    if (!side) {
      return;
    }
    const target =
      side === "left"
        ? { x: PADDING, y: positionRef.current.y }
        : { x: SCREEN_WIDTH - PIP_WIDTH - PADDING, y: positionRef.current.y };
    positionRef.current = target;
    setTucked(null);
    void controls.start({
      ...target,
      transition: spring(0.35, 0.5),
    });
  };

  return (
    <PhoneFrame className="bg-[#0b1516]" tone="teal">
      <div className="px-5 pt-[60px]">
        <p className="text-[24px] font-bold text-white">Notes</p>
        <div className="mt-4 space-y-2.5">
          {NOTES.map(([title, body]) => (
            <div key={title} className="rounded-2xl bg-white/[0.05] p-4 ring-1 ring-white/[0.05]">
              <p className="text-[13px] font-semibold text-white/90">{title}</p>
              <p className="mt-0.5 text-[12px] text-white/40">{body}</p>
            </div>
          ))}
        </div>
      </div>

      <motion.div
        drag
        dragMomentum={false}
        dragElastic={0.12}
        initial={HOME}
        animate={controls}
        onDragEnd={(_, info) =>
          settle(info.offset.x, info.offset.y, info.velocity.x, info.velocity.y)
        }
        onClick={untuck}
        style={{ height: PIP_HEIGHT, width: PIP_WIDTH }}
        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"
      >
        <div className="absolute inset-0 bg-gradient-to-br from-[#3f6a68] via-[#20393b] to-[#112022]" />
        <motion.div
          aria-hidden
          className="absolute -top-6 left-8 size-24 rounded-full bg-[#8cc2ba]/30 blur-xl"
          animate={reduced ? undefined : { x: [0, 8, -4, 0], y: [0, 4, -2, 0] }}
          transition={{ duration: 6, ease: "easeInOut", repeat: Infinity }}
        />
        <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">
          NV
        </div>
        <div className="absolute bottom-1.5 left-2 flex items-center gap-1 text-white/80">
          <Video className="size-3" />
          <span className="text-[10px] font-medium">Nova</span>
        </div>
        <motion.div className="absolute right-2 bottom-1.5 flex items-end gap-[2px]" aria-hidden>
          {[0, 1, 2].map((bar) => (
            <motion.span
              key={bar}
              className="w-[3px] rounded-full bg-emerald-400"
              animate={reduced ? { height: 6 + bar * 2 } : { height: [3, 9 + bar * 2, 4, 8, 3] }}
              transition={
                reduced
                  ? { duration: 0 }
                  : { duration: 0.9 + bar * 0.2, ease: "easeInOut", repeat: Infinity }
              }
            />
          ))}
        </motion.div>

        {tucked && (
          <div
            className={`absolute inset-y-0 flex items-center bg-black/30 ${
              tucked === "left" ? "right-0 pr-1" : "left-0 pl-1"
            }`}
          >
            {tucked === "left" ? (
              <ChevronRight className="size-4 text-white/80" />
            ) : (
              <ChevronLeft className="size-4 text-white/80" />
            )}
          </div>
        )}
      </motion.div>

      <p className="pointer-events-none absolute inset-x-0 bottom-6 text-center text-[11px] text-white/30">
        Flick the call window · throw it off an edge to tuck it
      </p>

      <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">
        <Mic className="size-4 text-white/70" />
        <Video className="size-4 text-white/70" />
        <span className="size-4 rounded-full bg-red-500" />
      </div>
    </PhoneFrame>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import type { CSSProperties } from "react";

import { PipWindow } from "./pip-window";

// Faint scanlines nod to the video call without competing with the floating window.
const backdrop: CSSProperties = {
  background: [
    "repeating-linear-gradient(0deg, rgb(255 255 255 / 0.018) 0 1px, transparent 1px 4px)",
    "radial-gradient(ellipse 60% 45% at 50% 100%, rgb(46 120 116 / 0.35), transparent 75%)",
    "radial-gradient(ellipse 45% 55% at 50% 40%, rgb(30 78 78 / 0.4), transparent 70%)",
    "linear-gradient(180deg, #0a1c1d 0%, #07191a 45%, #031011 100%)",
  ].join(", "),
};

const Preview = () => (
  <main className="flex h-dvh items-center justify-center overflow-hidden p-5" style={backdrop}>
    <PipWindow />
  </main>
);

export default Preview;
```
