tabstrip
A browser tab strip for Wayland status bars, fed by tabctl. It shows the tabs of every browser window on the current workspace as clickable chips. Each chip carries the site favicon. Click a chip to activate the tab. Scroll to cycle.
tabstrip does the work and the bar only draws. The daemon writes a small JSON snapshot, and the bar calls back into the same binary on click and scroll. Any bar that can watch a file and run a command can host it. Wiring is given below for waybar and Quickshell.
The strip is scoped to the workspace, not to the focused window. Every chip is therefore a tab you can currently see, and the strip stays populated while a terminal or an editor has focus. Windows are ordered by screen position. Tabs are ordered by tab-bar position.
Built for Hyprland. One Go binary, plus resvg for SVG favicons.
How it works
browser extension → tabctl-mediator → D-Bus → tabstrip daemon → snapshot JSON → bar
The daemon listens for tabctl's TabsUpdated D-Bus signal and for Hyprland
window events. It then rewrites the snapshot file atomically (write, then
rename) and signals any running waybar with SIGRTMIN+8. Bars that watch the
file pick up the rename and need no signal. If the D-Bus signal is
unavailable, the daemon polls every 1.5s.
Tab changes reach the bar in about half a second. Most of that is deliberate debounce.
Requirements
- tabctl 2.2.0 or newer, with its browser
extension installed. Run
tabctl listto confirm it works. Both halves must be 2.2.0: the strip reads favicons throughListTabsWithIcons, and the extension suppliesfavIconUrl. Runtabctl statusto see both versions. - A bar to draw it: waybar, Quickshell, or anything that can watch a file.
- Hyprland. Workspace and window queries use its IPC sockets.
- resvg, to rasterise SVG favicons.
Optional: without it SVG icons fall back to the browser chip and everything
else still works. Go's image decoders cover the raster formats in-process.
On Arch,
pacman -S resvg.
Install
go install github.com/slastra/tabstrip@latest
Or clone the repository and run go build.
Usage
tabstrip daemon run persistently and keep the snapshot current
tabstrip snapshot one-shot refresh (debugging; default)
tabstrip goto N activate the Nth displayed tab
tabstrip next cycle to the next tab
tabstrip prev cycle to the previous tab
tabstrip list every tab in every browser window, as JSON
tabstrip activate ID activate a tab anywhere by its "<browser>.<win>.<tab>"
id, and follow it to its workspace
Only one daemon runs at a time. A new one terminates the old one through the pidfile, so restarting the bar or the session never leaves two writing the snapshot.
list is the global view. The snapshot is deliberately limited to the current
workspace, because the strip shows what you can see. list covers every
browser window on every workspace, for building a tab switcher, and activate
takes you to whichever window owns the tab.
Output formats
The snapshot at $XDG_RUNTIME_DIR/waybar-fftabs.json holds the chips in
display order:
{
"tabs": [
{
"id": "firefox.220.18",
"label": "2 slastra/tabstrip: Browser tab strip for…",
"icon": "/home/you/.cache/tabctl/favicons/0b7c33a8e1510a8a-processed.png",
"active": true
}
]
}
labelis the cleaned tab title with its slot number in front. It is HTML-escaped, because waybar renders it as Pango markup.iconalready reflects focus. It points at the dimmed variant for tabs in windows you are not in, so a bar should draw it as given and not dim it again.activemarks the chip to highlight.- An empty
tabsarray means the strip should render nothing.
tabstrip list prints a bare array with one object per tab:
[
{
"id": "firefox.1.1",
"title": "Inbox - Mail",
"icon": "/home/you/.cache/tabctl/favicons/0210caa4d6f4380c-processed.png",
"windowId": 1,
"active": false,
"browser": "Firefox"
}
]
Here title is the raw tab title and icon is always the bright chip. Pass
id to tabstrip activate.
Running the daemon
Start the daemon once per session. The simplest place is the compositor's autostart, which keeps it independent of whichever bar you run:
-- hyprland.lua
hl.on("hyprland.start", function()
hl.exec_cmd("tabstrip daemon &")
end)
With a hyprland.conf, use exec-once = tabstrip daemon.
On startup the daemon prints one line, {"text": ""}, so it can also run as a
hidden waybar module. Launched any other way, that line is harmless. The daemon
tolerates a missing mediator: if tabctl is not up yet at login, the strip
renders empty and fills in once the mediator appears.
Waybar wiring
Either start the daemon from autostart, or let waybar start it from a hidden
driver module. An image module renders the chips.
"custom/fftabsdrv": {
"exec": "tabstrip daemon",
"return-type": "json",
"format": "{}",
"tooltip": false
},
"image#fftabs": {
"exec": "jq -c '[.tabs | to_entries[] | {path: .value.icon, marker: (if .value.active then \"active\" else \"inactive\" end), tooltip: .value.label, \"on-click\": (\"tabstrip goto \" + ((.key+1)|tostring))}]' $XDG_RUNTIME_DIR/waybar-fftabs.json",
"signal": 8,
"on-scroll-up": "tabstrip next",
"on-scroll-down": "tabstrip prev"
}
The image module takes the chip count from the array, so per-tab slot modules
are not needed. Waybar's image module has no CSS class states. The daemon
therefore writes a dimmed copy beside each chip and selects between them.
Brightness marks the window you are in. Every tab of the focused browser window is bright and every other window on the workspace is dimmed, so the strip shows at a glance which chips belong to the window in front of you. When focus is on something that is not a browser, the daemon falls back to marking each window's active tab.
A text-only strip is still possible with custom/ slot modules. Read
.tabs[N].label in each module and set a class from .tabs[N].active. Those
modules cannot show favicons.
Quickshell wiring
Quickshell does not need the signal. A FileView with watchChanges sees the
daemon's rename and reloads. This is a minimal strip. Start the daemon from
autostart, as above.
import Quickshell
import Quickshell.Io
import QtQuick
Row {
id: strip
spacing: 8
property var tabs: []
FileView {
path: Quickshell.env("XDG_RUNTIME_DIR") + "/waybar-fftabs.json"
watchChanges: true
onFileChanged: reload()
onLoaded: {
try {
strip.tabs = JSON.parse(text()).tabs ?? []
} catch (e) {
// read landed mid-write; the next change event retries
}
}
}
Repeater {
model: strip.tabs
Image {
required property var modelData
required property int index
source: "file://" + modelData.icon
width: 24; height: 24
MouseArea {
anchors.fill: parent
onClicked: Quickshell.execDetached(["tabstrip", "goto", String(index + 1)])
onWheel: wheel => Quickshell.execDetached(
["tabstrip", wheel.angleDelta.y > 0 ? "next" : "prev"])
}
}
}
}
Tooltips come from modelData.label, and modelData.active drives any
highlight. A tab switcher runs tabstrip list through a Process, parses its
stdout, and passes the chosen id to tabstrip activate.
Favicons
Chips use the favicon the browser already resolved. tabstrip does not guess one
from the domain and does not call a third-party icon service. Most favicons
arrive as inline data: URIs, so the common case makes no network request.
Decoding runs in-process. PNG, JPEG and GIF use the standard library. SVG is rasterised at 256px and downsampled. ICO is parsed directly, because ICO directory headers often disagree with their payloads. One favicon declared 256x256 entries that held 16x16 PNGs. tabstrip therefore ignores the declared sizes, decodes every entry, and keeps the largest real one.
A tab with no usable favicon falls back to its browser icon, so rows stay aligned. tabstrip records a failure for six hours instead of retrying it on every tab event.
The daemon owns the cache and writes chips as tabs appear. Other tools can read
the same files. Each chip is named <sha256(favIconUrl)[:16]>-processed.png
and is 64x64 with rounded corners.
Configuration
There is no config file. The two values worth changing are environment variables, set wherever the daemon is started.
| Variable | Default | Purpose |
|---|---|---|
TABSTRIP_MAX_TABS |
16 | chips to render before truncating |
TABSTRIP_ICON_DIR |
$XDG_CACHE_HOME/tabctl/favicons |
favicon cache location |
env TABSTRIP_MAX_TABS=24 tabstrip daemon
goto, next and prev rewrite the snapshot after acting, and list reads
chips from the cache, so they need the same values as the daemon. Set the
variables in the session environment rather than on the daemon's command line
alone. Otherwise a click briefly re-renders the strip with the defaults.
How many chips fit depends on your screen width, so raise the cap if the strip stops short of the tabs you have open. Tabs past the cap are dropped silently.
Files
| Path | Purpose |
|---|---|
$XDG_RUNTIME_DIR/waybar-fftabs.json |
snapshot the chips render from |
$XDG_RUNTIME_DIR/waybar-fftabs.pid |
daemon pidfile, newer instances replace older ones |
$XDG_CACHE_HOME/tabctl/favicons/ |
favicon chips, written by the daemon, readable by other tools |
The runtime files keep their waybar- names from when waybar was the only
host. Bars depend on those paths, so they stay.
License
MIT