Skip to content

Instantly share code, notes, and snippets.

@bkuri
Created July 30, 2026 04:04
Show Gist options
  • Select an option

  • Save bkuri/8e017748546c8a812fb8161ade87d440 to your computer and use it in GitHub Desktop.

Select an option

Save bkuri/8e017748546c8a812fb8161ade87d440 to your computer and use it in GitHub Desktop.
Sticky picture-in-picture for niri — a tiny event-stream daemon that moves a floating PiP window to the focused workspace (workaround for niri#932)

Sticky picture-in-picture for niri

Keep a floating picture-in-picture window (browser PiP, mpv, a webcam, etc.) visible on every workspace you switch to — the "pin on all workspaces" behaviour other compositors have, for niri.

niri has no built-in sticky-window mode yet (niri#932). This is a ~30-line daemon that reaches the same result: it listens to niri's JSON event stream and moves the PiP window to whatever workspace you focus. No polling, no CPU while you sit still.

How it works

niri-pip-follow reads niri's event stream (niri msg -j event-stream):

  • On a WorkspacesChanged event it grabs the now-focused workspace id and, if the tracked PiP window isn't already there, runs niri msg action move-window-to-workspace.
  • On a WindowsChanged event it re-discovers the PiP's window id (so the move keeps targeting the right window as you open/close/resize it).

Because it reacts to events instead of polling, it costs nothing when idle.

Requirements

  • niri (IPC enabled — the default)
  • jq
  • bash (the script uses process substitution; plain POSIX sh/dash is not enough)

Setup

1. Float and size the PiP window

Add a window rule to your niri config (~/.config/niri/config.kdl). This one targets the Zen browser PiP — change app-id/title for Firefox, mpv, etc.

window-rule {
    match app-id="zen" title="^Picture-in-Picture$"
    open-floating true
    default-column-width { fixed 480; }
    default-window-height { fixed 270; }
    default-floating-position x=32 y=32 relative-to="bottom-left"
}

2. Install and launch the daemon

install -m755 niri-pip-follow ~/.local/bin/niri-pip-follow

Start it at login by adding this to your niri config:

spawn-at-startup "~/.local/bin/niri-pip-follow"

(On older configs the key is spawn-sh-at-startup.)

That's it — the PiP now follows you across workspaces.

Matching a different app

The matcher lives in two env vars at the top of the script:

App PIP_APP_ID PIP_TITLE
Zen Browser zen Picture-in-Picture
Firefox firefox Picture-in-Picture
mpv mpv (match by app-id only — see below)

Override per launch:

PIP_APP_ID=firefox PIP_TITLE=Picture-in-Picture niri-pip-follow

For mpv (where the window title is the filename), match by app-id only: in the WindowsChanged branch change the select(...) to select(.app_id == $app).

Optional: a manual "pull PiP here" keybind

niri-pull-pip is a one-shot version — pull the PiP to the current workspace on demand instead of always-on:

binds {
    Mod+P { spawn-sh "~/.local/bin/niri-pull-pip"; }
}

Useful if you only want it sometimes, or as a fallback to the daemon.

Limitations

  • The PiP follows the focused workspace; it does not render above fullscreen windows. Showing a pinned window over fullscreen is a separate niri feature (tracked in #932).
  • The window briefly disappears/reappears during the move (that's niri moving the window, not a flicker in the video itself).

License

MIT. Do whatever.

#!/usr/bin/env bash
# niri-pip-follow: keep a floating PiP window on the focused workspace.
#
# Listens to niri's JSON event stream and moves the matched window to whatever
# workspace you switch to. No polling, no CPU when idle. Pair with a floating
# window-rule (see README.md).
#
# Override the matcher with env vars, e.g. for Firefox:
# PIP_APP_ID=firefox PIP_TITLE=Picture-in-Picture niri-pip-follow
# To match by app-id only (e.g. mpv, where the title changes), drop the
# title half of the `select(...)` below.
set -u
PIP_APP_ID="${PIP_APP_ID:-zen}"
PIP_TITLE="${PIP_TITLE:-Picture-in-Picture}"
pip_id=""
pip_ws=""
while IFS= read -r line; do
case "$line" in
*WorkspacesChanged*)
new_ws=$(echo "$line" | jq -r '.WorkspacesChanged.workspaces[] | select(.is_focused) | .id')
[ -z "$new_ws" ] && continue
[ -z "$pip_id" ] && continue
[ "$pip_ws" = "$new_ws" ] && continue
echo "niri-pip-follow: moving $pip_id to workspace $new_ws" >&2
niri msg action move-window-to-workspace "$new_ws" --window-id "$pip_id" --focus false
pip_ws="$new_ws"
;;
*WindowsChanged*)
pip_info=$(echo "$line" | jq -r --arg app "$PIP_APP_ID" --arg title "$PIP_TITLE" \
'[.WindowsChanged.windows[] | select(.app_id == $app and .title == $title) | {id, workspace_id}] | first // empty | "\(.id) \(.workspace_id)"')
if [ -n "$pip_info" ]; then
pip_id=$(echo "$pip_info" | awk '{print $1}')
pip_ws=$(echo "$pip_info" | awk '{print $2}')
else
pip_id=""
pip_ws=""
fi
;;
esac
done < <(niri msg -j event-stream)
#!/usr/bin/env bash
# niri-pull-pip: one-shot — pull the PiP window to the current workspace.
# Handy as a keybind, or as a manual fallback to the auto-follow daemon.
set -u
PIP_APP_ID="${PIP_APP_ID:-zen}"
PIP_TITLE="${PIP_TITLE:-Picture-in-Picture}"
pip_id=$(niri msg -j windows | jq -r --arg app "$PIP_APP_ID" --arg title "$PIP_TITLE" \
'.[] | select(.app_id == $app and .title == $title) | .id')
if [ -z "$pip_id" ] || [ "$pip_id" = "null" ]; then
echo "No PiP window found" >&2
exit 1
fi
current_ws=$(niri msg -j workspaces | jq -r '.[] | select(.is_focused) | .id')
niri msg action move-window-to-workspace "$current_ws" --window-id "$pip_id" --focus false
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment