Skip to main content

Layout

SnapPiP

A floating picture inside a container, such as the person's own camera in a call, that they drag and throw into a corner, where it comes to rest, as a phone's picture in picture does. Put it in a positioned container (relative); it moves within that container's box.

Mara Lind

Call controls
Bottom right. Option-arrow keys to move it

Throw it to a corner, or focus it and press Option and an arrow key. It rests in: bottom-right.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/snap-pip#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/snap-pip

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/snap-pip#v1.3.10 ourostack/ouro-ui/snap-pip-tests#v1.3.10
import { SnapPiP } from "@/components/ouro/snap-pip/snap-pip";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<div className="relative h-dvh">
  <SpeakerStage />
  <SnapPiP label="Your camera" corner={corner} onCornerChange={setCorner} keepOut={[toolbarRef]}>
    <video ref={selfView} autoPlay muted playsInline className="size-full -scale-x-100 object-cover" />
  </SnapPiP>
</div>

Usage

It follows the pointer from the first pixel and lifts while dragged. On release it works out where the throw would coast to (project, at scrolling's deceleration of 0.998), picks the corner whose place, clear of the keep-outs, is nearest that point, and springs there from the release speed (response 0.3s; damping 0.8 after a flick, so it overshoots a little, and 1 after a slow drop). Pressing it in flight catches it. While a pointer holds it, a keep-out or the container changing waits for the release, so it never slides out from under the finger. Dragged past the container's edge, it resists more and more (rubberband).

The corners sit inset in from the edges and keep clear of keepOut. It keeps its corner, not its position: when the keep-outs change it follows its corner with the spring, and while the container resizes it stays in its corner without one, placed in the same frame as the resize. While it springs it carries data-ouro-settling.

It is focusable: Option and an arrow key move it to the next corner that way, with the same spring, and the new corner is announced politely once it settles, never during the move; a key toward the side it is already on says the corner again. Its name, corner and hint are what screen readers read. The hint also shows beside it, toward the middle of the stage and never over it, when it gets keyboard focus or the pointer rests on it, and stays while the pointer is on either. Escape, a move or a press puts it away until focus and the pointer have both left. Double-click it, or press Option-Return, to toggle small and large.

With reduced motion it jumps straight to its corner: no spring, no lift and no rubberband.

Its agent side, snapPipAx in snap-pip-styles.ts, declares no reads and one action, move, which only the person takes: where their own picture sits is theirs. Hiding the picture is the app's, from its own menu, so the app declares that hide action, not SnapPiP.

Use instead when

  • Use Popover or HoverCard instead for an overlay anchored to a control, Resizable for a split layout the person sizes, and Sheet for a side panel.

Props

Type: SnapPiPProps.

aspectRatio
Type: number
Default: 1.6
Width over height, such as a camera's: 1.6 (16:10), or 0.5625 (9:16) for a phone held upright.
children
Type: ReactNode
The app's content, such as a <video> with className="size-full object-cover", which the app renders itself.
className
Type: string
Layout only. Appearance classes warn in development; style what you put inside it instead.
corner
Type: "top-left" | "top-right" | "bottom-left" | "bottom-right"
The corner it rests in, controlled. Pass onCornerChange too, or a throw comes back to this corner.
cornerLabels
Type: SnapPiPCornerLabels
Default: { "top-left": "Top left", "top-right": "Top right", "bottom-left": "Bottom left", "bottom-right": "Bottom right" }
The words for each corner, read after its name and announced when it settles in a new one.
defaultCorner
Type: "top-left" | "top-right" | "bottom-left" | "bottom-right"
Default: "bottom-right"
The corner it starts in, uncontrolled.
defaultSize
Type: "small" | "large"
Default: "small"
The size it starts at, uncontrolled.
hint
Type: string
Default: "Option-arrow keys to move it"
How to move it from the keyboard, read after its name and corner, and shown beside it as a tooltip. Translate it with the rest of your interface.
id
Type: string
The picture's id.
inset
Type: number
Default: 16
How far the corners sit in from the container's edges, and from a keep-out, in px.
keepOut
Type: readonly SnapPiPKeepOut[]
What the corners keep clear of: rectangles in the container ({ x, y, width, height }, px from its top-left corner), or refs to elements, such as a toolbar's band, a captions band or another SnapPiP, measured whenever they or the container resize. A ref is read again after each render and whenever the container's content changes, so an element the app shows or takes away behind the same ref, such as a captions band, counts as soon as it comes or goes. A corner that would overlap one moves just clear of it, toward the middle. When they change, the picture follows its corner with the spring.
label (required)
Type: string
What the picture is: "Your camera". It names it for screen readers, before its corner and the hint.
onCornerChange
Type: ((corner: SnapPiPCorner) => void)
Called with the new corner when the person throws or moves it there, so the app can remember it.
onSizeChange
Type: ((size: SnapPiPSize) => void)
Called with the new size when the person double-clicks it or presses Option-Return on it.
ref
Type: Ref<HTMLDivElement>
Reaches the rendered element.
size
Type: "small" | "large"
Its size, controlled. Small and large widths follow the container's width: under 900px, 160 and 240; from 900px, a sixth of it (160 to 320) and twice that, up to a third; from 1800px, 288 and 480.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • avoid: Not a prop. Ouro UI uses keepOut: <SnapPiP keepOut={[toolbarRef]}>.
  • offset: Not a prop. Ouro UI uses inset: <SnapPiP inset={16}>.
  • position: Not a prop. It rests in a corner: <SnapPiP corner="bottom-right">.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

SnapPiPCorner

A corner a SnapPiP rests in: the screen's corners, the same in either reading direction.

type SnapPiPCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";

SnapPiPCornerLabels

The words screen readers hear for a corner when the picture settles in it.

type SnapPiPCornerLabels = Record<SnapPiPCorner, string>;

SnapPiPKeepOut

Something the corners keep clear of: a rectangle in the container, or a ref to an element whose box is measured.

type SnapPiPKeepOut = SnapPiPRect | RefObject<Element | null>;

SnapPiPRect

A rectangle in the container, in px from its top-left corner (inside its border).

interface SnapPiPRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

SnapPiPSize

The sizes a SnapPiP toggles between.

type SnapPiPSize = "small" | "large";