npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@wcstack/picture-in-picture

v3.2.0

Published

Declarative Picture-in-Picture component for Web Components. Framework-agnostic <video> Picture-in-Picture control via wc-bindable-protocol.

Readme

@wcstack/picture-in-picture

🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.

@wcstack/picture-in-picture is a headless Picture-in-Picture component for the wcstack ecosystem.

It is not a visual UI widget. It is an async primitive node that turns a <video> element's Picture-in-Picture state into reactive state — the same way @wcstack/fullscreen turns an element's fullscreen state into reactive state.

With @wcstack/state, <wcs-pip> can be bound directly through path contracts:

  • input surface: target — which <video> element to control
  • output state surface: active, error, errorInfo
  • command surface: requestPictureInPicture(), exitPictureInPicture()

This means a "pop out" video button — with no live-region layout thrashing, no manual document.pictureInPictureElement polling — can be expressed declaratively in HTML.

@wcstack/picture-in-picture follows the CSBC (Core / Shell / Binding Contract) architecture:

  • Core (PipCore) resolves the Picture-in-Picture API (call-time, never cached), subscribes to enterpictureinpicture/leavepictureinpicture on the target <video>, and tracks active/error
  • Shell (<wcs-pip>) resolves which <video> element to control from the DOM, manages display, lifecycle, and declarative commands
  • Binding Contract (static wcBindable) declares observable properties, writable inputs, and callable commands

Why this exists — and why it doesn't operate on itself

Like @wcstack/fullscreen, this Shell is a non-visible control tag: it does not put itself into Picture-in-Picture. It resolves a target element and invokes the Picture-in-Picture API against that element. The typical use case is a video player's "pop out" button.

@wcstack/picture-in-picture shares its target-resolution archetype with @wcstack/fullscreen and @wcstack/intersection (see docs/fullscreen-tag-design.md §1 for the detailed rationale) — the same 3-mode target resolution, the same _safeQuery never-throw wrapper, the same single Core-level _gen generation guard, and the same simple error field (no permission-style 4-value state; see docs/fullscreen-tag-design.md §8).

Scope: classic Picture-in-Picture API only (<video>-only)

There are two unrelated web platform proposals under the "Picture-in-Picture" name:

  • The classic Picture-in-Picture API (HTMLVideoElement.requestPictureInPicture()) — <video>-only, broadly supported. This is what <wcs-pip> wraps.
  • The Document Picture-in-Picture API (documentPictureInPicture.requestWindow()) — lets you float an arbitrary DOM subtree in a separate always-on-top window, not limited to video. Its API shape (acquiring a separate Window and moving DOM into it) is fundamentally different from the "resolve a target, watch a document-level state" archetype this node shares with fullscreen/pointer-lock.

<wcs-pip> targets the classic, <video>-only API. The Document Picture-in-Picture API is out of scope for v1 — see docs/picture-in-picture-tag-design.md §4. It may become a separate node (e.g. <wcs-doc-pip>) in the future.

The target MUST resolve to a <video> element

Unlike Fullscreen (which any Element supports), Picture-in-Picture is only defined on HTMLVideoElement. If target resolves to a non-<video> element, <wcs-pip> treats it the same as an unresolved target: requestPictureInPicture() never throws — it immediately sets error to { message: "target must be a <video> element." } and resolves.

Install

npm install @wcstack/picture-in-picture

Quick Start

1. A "pop out" button for a video player

<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/picture-in-picture/auto"></script>

<wcs-state>
  <script type="module">
    export default {
      $commandTokens: ["popOut", "backToPage"],
      pipActive: false,
    };
  </script>
</wcs-state>

<video id="player" src="/movie.mp4" controls></video>

<wcs-pip target="#player" data-wcs="active: pipActive; command.requestPictureInPicture: $command.popOut; command.exitPictureInPicture: $command.backToPage"></wcs-pip>

<button data-wcs="onclick: $command.popOut">Pop out</button>
<button data-wcs="onclick: $command.backToPage; hidden: pipActive|not">Back to page</button>

Neither button touches <wcs-pip> directly: each click emits a command token, and <wcs-pip> subscribes to those tokens via command.requestPictureInPicture: $command.popOut / command.exitPictureInPicture: $command.backToPage (the command-token protocol — the element with the command method is the subscriber, not the emitter). Every bound state path must be declared up front — pipActive: false here; binding an undeclared path throws at initialization. Negation in a data-wcs path is done with the |not filter (pipActive|not), not a leading ! — paths do not support prefix operators.

2. Wrapping the <video> as a child (no selector needed)

<wcs-state>
  <script type="module">
    export default {
      pipActive: false,
    };
  </script>
</wcs-state>

<wcs-pip data-wcs="active: pipActive">
  <video src="/movie.mp4" controls></video>
</wcs-pip>

3. Reporting failures (e.g. gesture-context rejection)

error and errorInfo are observable outputs (both fire an event — see "Output state" below), so you can bind them like active, or read them imperatively after the command's promise settles:

<wcs-state>
  <script type="module">
    export default {
      $commandTokens: ["popOut"],
      pipActive: false,
      pipError: null,
    };
  </script>
</wcs-state>

<wcs-pip target="#player" data-wcs="active: pipActive; error: pipError; command.requestPictureInPicture: $command.popOut"></wcs-pip>
<button data-wcs="onclick: $command.popOut">Pop out</button>

Or imperatively, reading errorInfo for the stable failure taxonomy:

const pip = document.querySelector("wcs-pip");
await pip.requestPictureInPicture();
if (pip.error) {
  console.log("Could not enter Picture-in-Picture:", pip.error);
  console.log(pip.errorInfo); // { code: "not-allowed", phase: "execute", recoverable: true, message: "..." }
}

The target attribute decides what is controlled

| target | controls | display | use case | |-----------------|-------------------------|-------------|--------------------------| | omitted | first element child | contents | wrap a <video> inline | | "#player" / sel | the matched element | none | separate control tag | | "self" | the element itself | block | always fails<wcs-pip> itself can never be a <video>, so requestPictureInPicture() immediately errors (see "The target MUST resolve to a <video> element" above) |

display:contents means wrapping a <video> child injects no box of its own. Only the explicit target="self" sentinel takes a box. See packages/intersection's _resolveTarget() — this Shell reuses it verbatim (docs/fullscreen-tag-design.md §1).

Unlike @wcstack/fullscreen (where target="self" is a legitimate way to fullscreen the wrapper itself, since any Element can be fullscreened), target="self" here is a structural dead end: <wcs-pip>'s own tagName is never VIDEO, so it always fails the <video>-only check and every requestPictureInPicture() call resolves into error. The mode is still accepted rather than rejected as an invalid attribute value — for parity with the shared 3-mode _resolveTarget() archetype — it just never does anything useful.

Attributes

| Attribute | Type | Default | Description | |-----------|--------|-------------|--------------| | target | string | (omitted) | Which <video> to control: omitted → first child, a selector → that element, self → this element (always fails — see above). |

Output state

| Property | Type | Event | Description | |-------------|--------------------------|------------------------------|--------------| | active | boolean | wcs-pip:change | Whether the resolved <video> target is currently the document's Picture-in-Picture element. | | error | any | wcs-pip:error | The most recent command failure (wrong tag, unsupported API, gesture-context rejection), or null if the last attempt succeeded / nothing has failed yet. Value shape unchanged from before it became observable. | | errorInfo | WcsIoErrorInfo \| null | wcs-pip:error-info-changed | Additive serializable failure taxonomy derived from error: a stable { code, phase, recoverable, message }. null in lockstep with error. See the taxonomy table below. |

active is derived from comparing document.pictureInPictureElement against the resolved <video> target whenever enterpictureinpicture/leavepictureinpicture fires on that target element — not from a document-level event (see "Event subscription" below).

errorInfo taxonomy

errorInfo is an additive projection of error (it never changes error's own value shape) that gives a stable, serializable code you can branch on — safe to log or forward to DevTools/remote without carrying a non-cloneable exception. It moves null↔non-null exactly in step with error, and its wcs-pip:error-info-changed event fires immediately before wcs-pip:error.

| code | phase | recoverable | When | |----------------------|-----------|---------------|------| | capability-missing | probe | false | The Picture-in-Picture API is not available ({ message: "Picture-in-Picture API is not supported." }). | | invalid-argument | start | false | target did not resolve to a <video> element ({ message: "target must be a <video> element." }). | | not-allowed | execute | true | The request was rejected with NotAllowedError / TypeError (e.g. called outside a user gesture) — retrying from within a genuine gesture may succeed. | | pip-error | execute | false | Any other caught exception from the platform call. |

Commands

| Command | Description | |-------------------------------|--------------| | requestPictureInPicture() | Resolve the target <video> and request Picture-in-Picture for it. Never throws: a non-<video> target, an unsupported API, or a gesture-context rejection (NotAllowedError) all funnel into error. | | exitPictureInPicture() | Exit Picture-in-Picture. Silent no-op if nothing is currently in Picture-in-Picture (mirrors @wcstack/fullscreen's exitFullscreen() — see docs/fullscreen-tag-design.md §7). |

User gesture requirement

requestPictureInPicture() must be called from within a user gesture (e.g. a click handler). This is a browser-level requirement <wcs-pip> cannot work around — see docs/fullscreen-tag-design.md §3 for the same constraint on Fullscreen. Prefer wiring the command directly to a click via the command-token protocol (command.requestPictureInPicture: $command.<token> on <wcs-pip>, emitted by a button's onclick: $command.<token> — see the Quick Start above), making sure the triggering event itself is a genuine user gesture.

Calling it from inside a setTimeout or deep inside a .then() chain loses the gesture context and the browser will reject the request — this is unrelated to wcstack and cannot be fixed at this layer.

Event subscription: the <video> element itself, not document

Unlike Fullscreen's fullscreenchange (which fires on document), Picture-in-Picture's enterpictureinpicture/leavepictureinpicture events fire on the <video> element itself. PipCore attaches these listeners directly to the resolved <video>, and re-wires them (detaching from the old target, attaching to the new one) whenever target is re-resolved (e.g. the target attribute changes).

This also means multiple <wcs-pip> instances naturally self-filter: each instance only hears events from its own <video> target, so one instance entering Picture-in-Picture never flips another instance's active to true (see docs/picture-in-picture-tag-design.md §5).

CSS styling with :state()

<wcs-pip> reflects one boolean output state onto its ElementInternals CustomStateSet, so you can style it directly from CSS with the :state() pseudo-class — no data-wcs binding or extra class toggling required.

| State | On when | |-------|---------| | active | wcs-pip:change fires with detail.active === true (cleared when it fires with false) |

wcs-pip:state(active) ~ .back-to-page-button { display: inline-block; }
wcs-pip:state(active) ~ .back-to-page-button { display: none; } /* default */

Unlike attributes or classes, :state() cannot be written from outside the element, so there is no risk of confusing this output state with an input. error/errorInfo are intentionally not reflected as :state() — only the boolean active maps cleanly to a presence/absence pseudo-class. (They are observable via their own events for data-wcs binding — see "Output state" above.)

Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+, Firefox 126+. In older browsers the states are simply never set — :state() selectors never match, but <wcs-pip> itself keeps working normally (graceful degradation, never-throw).

SSR: :state() cannot be serialized into HTML, so server-rendered markup never carries these states on first paint (@wcstack/server is unaffected). If you need to style the pre-hydration gap, pair your rule with wcs-pip:not(:defined) instead.

Debugging

Custom states are invisible in DevTools' Elements panel and attachInternals() cannot be called twice, so there is no console way to inspect them directly. Two debug-only aids are provided for that:

  • el.debugStates — a snapshot array of the currently-on state names (e.g. ["active"]). It is not part of wc-bindable (not a bind target) and its shape is not a guaranteed contract — use it for debugging only.

  • The debug-states attribute (opt-in, default off) mirrors state changes onto a data-wcs-state-active attribute on the element, so the Elements panel highlights it as it toggles:

    <wcs-pip target="#player" debug-states></wcs-pip>

Write your CSS against :state(), not data-wcs-state-*. The mirrored attribute exists purely to make state changes visible while debugging with DevTools open; it is not a supported styling hook.

Binding Contract (wcBindable)

Both the Core and the Shell declare the wc-bindable protocol.

// PipCore (headless)
PipCore.wcBindable = {
  protocol: "wc-bindable",
  version: 1,
  properties: [
    { name: "active", event: "wcs-pip:change", getter: (e) => e.detail.active },
    { name: "error", event: "wcs-pip:error" },
    { name: "errorInfo", event: "wcs-pip:error-info-changed" },
  ],
  commands: [
    { name: "requestPictureInPicture", async: true },
    { name: "exitPictureInPicture", async: true },
  ],
};

The Shell (<wcs-pip>) inherits the Core's properties/commands and declares the target input.

Using the Core standalone

PipCore is framework-agnostic. You resolve and hand it the <video> element to control (the Shell does this resolution for you):

import { PipCore } from "@wcstack/picture-in-picture";

const core = new PipCore();
core.addEventListener("wcs-pip:change", (e) => {
  console.log(e.detail.active);
});

const video = document.querySelector("video");
core.observe(video);            // subscribe to enterpictureinpicture/leavepictureinpicture
await core.requestPictureInPicture(video);

// later
await core.exitPictureInPicture();
core.dispose();                 // detach listeners

The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.

Notes & limitations

  • <video>-only. target must resolve to an HTMLVideoElement. Any other element is treated as unresolved: error is set to { message: "target must be a <video> element." }, never throws.
  • Document Picture-in-Picture API is out of scope. See "Scope" above.
  • Never throws. Unsupported environments, wrong-tag targets, and gesture-context rejections are all funneled into error.
  • document.pictureInPictureElement is a single document-wide value, like document.fullscreenElement. Multiple <wcs-pip> instances self-filter via their own <video> target's enterpictureinpicture/leavepictureinpicture listeners — see "Event subscription" above. Note the asymmetry, though: exitPictureInPicture() is not scoped per instance — it calls the document-global document.exitPictureInPicture(), so invoking it on any instance exits whatever <video> is currently in Picture-in-Picture, even one entered via another instance's target (its silent no-op check is likewise document-wide: "is anything in Picture-in-Picture", not "is my target in Picture-in-Picture"). This mirrors the platform API itself and @wcstack/fullscreen's exitFullscreen() (see docs/fullscreen-tag-design.md §7's "scope note" alongside §2.1, and the "Multiple instances" bullet in packages/fullscreen/README.md).
  • No desired/actual two-phase state — this node exposes a single active boolean plus the error/errorInfo failure pair, mirroring @wcstack/fullscreen's simpler-than-permission state model.
  • error and errorInfo are observable (bindable). Both are declared in static wcBindable.properties with their own events (wcs-pip:error / wcs-pip:error-info-changed), so a binding system can subscribe to a request/exit failure via data-wcs (e.g. error: pipError). errorInfo is the additive serializable taxonomy derived from error (see "Output state"); the error value shape is unchanged. You can still read element.error / element.errorInfo imperatively after a command's promise settles (e.g. await el.requestPictureInPicture(); if (el.error) { ... }).

License

MIT