A small Lua library for Hyprland that folds standalone tiled windows into a workspace tab group: one tile is visible, the rest sit behind it as tabs. Think monocle layout, but with the tab strip on top and keybinds to flip between cards, reorder them, and throw them at other workspaces or monitors.
It is built on Hyprland's Lua group/fullscreen API from v0.56.0 and is
loaded from your hyprland.lua like any other Lua plugin.
When loaded, hyprdeck keeps one invariant on every non-special workspace:
Every ordinary tiled window is reconciled into a tab group whenever native grouping policy permits. New windows normally join the focused group; any orphan left after mapping joins the leftmost eligible group.
Multiple groupbars are intentional. Existing groups — including one-window groups created by moving a tab past a group edge — are left intact rather than merged implicitly. If every existing group is locked or denied, an orphan bootstraps a new group instead of modifying one of those protected groups.
That happens automatically — you do not need to call anything. Hyprland's native
group.auto_group path adds an ordinary new window to the group of the focused
window on the same workspace. If mapping leaves a standalone tile, settled
reconciliation uses the leftmost group whose exposed policy allows additions.
Hidden workspaces use the same fallback without first becoming visible.
An unclassified native rejection, including global group lock, is not bypassed
with a forced singleton; that orphan remains untouched until a later settled
batch can retry. This preserves an explicit lock instead of manufacturing a new
group behind it.
Floating and fullscreen windows are left alone, and a workspace that contains a
fullscreen window is "frozen" — hyprdeck won't touch its groups until the window
leaves fullscreen. A grouped window keeps its membership and tab position while
fullscreen; a standalone fullscreen window is considered for grouping only after
it returns to tiled state.
Automatic reconciliation and fullscreen close-cascade repair intentionally skip
special workspaces. This ownership boundary does not disable explicit
hyd.dsp.* commands: the same focus and move bindings continue to work with a
special source or destination according to their normal contracts, so users do
not need separate native hotkeys for scratchpads. Hyprland's required global
group.auto_group setting can also group eligible windows on an active special
workspace independently of hyprdeck's automatic reconciliation.
Production events are reconciled after their compositor transaction settles,
not from inside the event callback. This matters when a fullscreen window is
closing: it prevents an already-closing standalone window from being inserted
into a group and becoming an artificial nextInGroup fullscreen successor.
On top of that, hyprdeck ships replacements for Hyprland's focus and
window.move dispatchers. They behave like the originals everywhere
except in three cases that are otherwise painful to script:
- Group-aware horizontal focus. Inside a multi-tab group,
left/rightcycle through tabs; at the edge they fall through to spatial focus (so you cross over to the neighbouring monitor as usual). - Group-aware horizontal move. Inside a group,
left/rightreorder the current tab. At the edge they attempt a native directional merge when a tiled neighbour exists on the same workspace. A rejected target leaves source membership unchanged. If no such neighbour exists, the active member of an unlocked multi-member group is split into a focused one-window group; a sole member stays unchanged. A locally locked source always stays unchanged, even ifbinds.ignore_group_lockwould let the native dispatcher bypass it. - Detach-before-move. Moving a window to another workspace or monitor directly removes only that member from its group before the destination move, so the receiving workspace doesn't end up with a foreign tab welded to its deck. This explicit cross-target operation bypasses group locks; a failed or unverifiable removal aborts the destination move.
- Hyprland
>= v0.56.0(stable window selectors and group/fullscreen API).
Clone the repo somewhere your Lua loader can see it. The simplest spot is your Hyprland config directory:
git clone https://github.com/chpock/hyprdeck ~/.config/hypr/hyprdeckThen in ~/.config/hypr/hyprland.lua, require it once:
local hyd = require("hyprdeck")That's the whole installation. require runs the module's initialisation,
which seeds defaults, subscribes the event handlers, and returns the
public API table. There is no separate "enable" step.
The default install above works because Hyprland's Lua loader looks for
modules under ~/.config/hypr/ — so a clone at ~/.config/hypr/hyprdeck
is found by require("hyprdeck") automatically. If you'd rather keep the
checkout somewhere else (a shared ~/src, a Nix store path, etc.), you
have two equivalent options.
Option 1: symlink. Make the custom location reachable from the
default place. Nothing in hyprland.lua has to change:
git clone https://github.com/chpock/hyprdeck /path/to/hyprdeck
ln -s /path/to/hyprdeck ~/.config/hypr/hyprdeckOption 2: extend package.path. Tell Lua where to look, then require
as usual. The line must come before the require("hyprdeck") call.
Two patterns work:
-- Point at the PARENT directory of the checkout. `?` is substituted with
-- the module name ("hyprdeck"), so Lua resolves it to
-- /path/to/hyprdeck/init.lua.
package.path = "/path/to/?/init.lua;" .. package.path
-- Or point straight at the lua/ subdirectory inside the checkout. This
-- skips the top-level init.lua shim and loads lua/hyprdeck.lua directly.
package.path = "/path/to/hyprdeck/lua/?.lua;" .. package.path
local hyd = require("hyprdeck")A common pitfall: writing "/path/to/hyprdeck/?/init.lua" instead of
"/path/to/?/init.lua". The ? expands to hyprdeck, so the first
form looks for /path/to/hyprdeck/hyprdeck/init.lua and fails with
module 'hyprdeck' not found.
hyprdeck has sensible defaults; calling setup is only needed if you want
to override them. It is idempotent, safe to call multiple times, and never
produces duplicate event handlers:
local hyd = require("hyprdeck").setup({
log_level = "info", -- "error" | "warning" | "info" | "debug" | "trace"
})| Option | Type | Default | Description |
|---|---|---|---|
log_level |
string | "info" |
Verbosity of Hyprland-log output (see below). |
errors also surface as a 10-second red Hyprland notification, so a
misconfigured binding is hard to miss. Setting log_level = "trace"
additionally subscribes a set of read-only event handlers that log
window/workspace transitions — useful when reporting a bug, noisy
otherwise. Each trace window snapshot includes immutable identity, internal and
client fullscreen modes, handler routing, pin/fullscreen conversion state, and
Hyprland's raw allowed_over_fullscreen override flag. That flag is not the full
effective visibility decision: the fullscreen owner, pinned windows, and members
of the owner's group are admitted through separate native conditions.
hyprdeck writes through Hyprland's Lua logger. Follow Lua log messages in real time with:
script -qfec 'hyprctl rollinglog -f' /dev/null | grep --line-buffered -iF '[Lua]'hyprdeck only works well when a couple of Hyprland options are set the
way it expects. The group.auto_group setting in particular is effectively
required — without it, every new window would have to be merged by
hyprdeck after the fact, and you'd see a visible flicker.
The following block is the recommended baseline. Drop it into your
hl.config({ ... }):
hl.config({
-- OPTIONAL. Hyprdeck works with any tiled layout. Uncomment this block if
-- you want one groupbar to occupy the master area while additional
-- groupbars form the slave stack. Each group is one layout target, so
-- switching tabs or focusing another group does not automatically promote
-- that group to the master area; Hyprland's master-layout policy controls
-- which target is master.
-- general = {
-- layout = "master",
-- },
group = {
-- REQUIRED. New tiled windows automatically merge into the focused
-- group on the same workspace. Without this, hyprdeck would have to
-- merge every window after window.open and you'd see a flicker as the
-- layout settles.
auto_group = true,
-- REQUIRED for a clean directional edge split. Native out_of_group keeps
-- focus on the source group while hyprdeck wraps the extracted window in
-- its final singleton, then hyprdeck focuses that singleton explicitly.
-- Workspace/monitor moves use direct Group:remove and do not depend on
-- this setting.
focus_removed_window = false,
-- Recommended, but not required (and true by default in Hyprland).
-- With true, a newly opened window or a window moved into an existing
-- group is inserted immediately after that group's current tab. This
-- keeps related arrivals close to the tab you were using. With false,
-- the same window is appended at the end of the group instead. It still
-- joins the correct group and becomes its current/focused member; only
-- its position in the tab strip changes. Both values produce the same
-- result when the target is a one-window group, because "after current"
-- and "at the end" are then the same insertion position.
insert_after_current = true,
-- Recommended, but not required (false by default in Hyprland).
-- With true, a workspace/monitor move immediately joins the moved window
-- to the destination's group when that workspace has exactly one visible
-- grouped tile and the group accepts it. This avoids leaving a loose tile
-- on a hidden destination, where no activation event would immediately
-- request reconciliation. With false, the move still succeeds: hyprdeck
-- bootstraps a one-window group on an empty destination, while a window
-- moved to a non-empty destination remains standalone until a later
-- subscribed event (such as workspace activation, window open, or config
-- refresh) reconciles it into the leftmost eligible group. When the
-- destination already has multiple groupbars, Hyprland cannot choose one
-- for this option, so neither value causes an immediate native merge;
-- later hyprdeck reconciliation still uses the leftmost eligible fallback.
group_on_movetoworkspace = true,
-- The tab strip is the surface you're actually looking at, so it's
-- worth styling. The block below is the author's setup — feel free
-- to swap colours and sizes; hyprdeck doesn't require any of it.
groupbar = {
enabled = true,
-- Recommended, but not required (false by default in Hyprland). Keeping
-- this false makes intentional one-window decks visible and avoids a
-- content resize when a second tab appears. See the comparison below.
disable_when_only = false,
render_titles = true,
scrolling = true,
middle_click_close = true,
height = 22,
font_size = 12,
font_family = "Sans",
text_padding = 0,
-- Render full per-tab backgrounds instead of just a thin line.
gradients = true,
indicator_height = 0,
col = {
active = "rgba(33ccff30)",
inactive = "rgba(2a2a2a55)",
},
text_color = "rgba(ffffffdd)",
text_color_inactive = "rgba(b0b0b0ff)",
-- Round each tab individually rather than only the outer edges
-- of the whole bar.
gradient_rounding = 8,
gradient_round_only_edges = false,
-- Visual separator between tabs (substitutes for a per-tab border).
gaps_in = 8,
gaps_out = 5,
keep_upper_gap = false,
},
},
misc = {
-- Recommended. This prevents native fullscreen retention for ordinary
-- standalone successors. Hyprland still transfers fullscreen to a
-- grouped nextInGroup regardless of this option; after the close event
-- settles, hyprdeck identifies that grouped inheritance and explicitly
-- unsets it without removing or re-inserting any group member.
exit_window_retains_fullscreen = false,
},
})group.groupbar.disable_when_only changes only whether Hyprland draws and
reserves space for a groupbar whose native group has one member. It does not
dissolve that group. With either value, an intentional singleton created by a
directional edge split remains distinct from a standalone orphan, survives later
reconciliation, and can accept another member.
The recommended value is false:
- one-window decks remain visibly identifiable as groups, including the focused singleton produced by a directional edge split;
- groupbar titles, lock feedback, and mouse/drag interaction remain available;
- the reserved groupbar height stays stable when membership changes between one and two windows, avoiding an extra application content resize;
- the groupbar still consumes vertical space when there is only one window and no tab switching is currently possible.
Set it to true if reclaiming that space and reducing one-window chrome matters
more than keeping singleton groups visible:
- a one-member group gives its groupbar area back to application content;
- the bar appears automatically when a second member joins and disappears again when the group returns to one member;
- each
1 -> 2or2 -> 1transition changes the reserved decoration extent and therefore resizes the application content area; - an intentional singleton becomes visually indistinguishable from a standalone window even though hyprdeck still preserves its native group membership;
- while hidden, that singleton has no groupbar surface for title/lock feedback, clicking, or dragging.
The dispatcher constructors return closures that you hand straight to
hl.bind:
local hyd = require("hyprdeck")
local mod = "SUPER"
-- Group-aware focus. Horizontal cycles tabs inside a group, then falls
-- through to spatial focus. Vertical is always spatial.
hl.bind(mod .. " + left", hyd.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + right", hyd.dsp.focus({ direction = "right" }))
hl.bind(mod .. " + up", hyd.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + down", hyd.dsp.focus({ direction = "down" }))
-- Group-aware move. Reorders inside the group; at the edge merges with
-- the neighbour group or ejects.
hl.bind(mod .. " + SHIFT + left", hyd.dsp.window.move({ direction = "left" }))
hl.bind(mod .. " + SHIFT + right", hyd.dsp.window.move({ direction = "right" }))
hl.bind(mod .. " + SHIFT + up", hyd.dsp.window.move({ direction = "up" }))
hl.bind(mod .. " + SHIFT + down", hyd.dsp.window.move({ direction = "down" }))
-- Switch workspaces, and send the current window to another workspace.
-- The move variant ejects from the current group first.
for i = 1, 10 do
local key = i % 10 -- 10 maps to key 0
hl.bind(mod .. " + " .. key, hyd.dsp.focus({ workspace = i }))
hl.bind(mod .. " + SHIFT + " .. key, hyd.dsp.window.move({ workspace = i, follow = true }))
end
-- Send the current window to the next/previous monitor. Ejects first.
hl.bind(mod .. " + CONTROL + up", hyd.dsp.window.move({ monitor = "+1" }))
hl.bind(mod .. " + CONTROL + down", hyd.dsp.window.move({ monitor = "-1" }))Hyprland v0.56 accepts a Lua function as a gesture action. Hyprdeck's
dispatcher constructors already return callable closures, so they can be passed
directly to hl.gesture without a wrapper or a separate gesture subsystem:
local hyd = require("hyprdeck")
hl.gesture({
fingers = 4,
direction = "left",
action = hyd.dsp.focus({ direction = "left" }),
})
hl.gesture({
fingers = 4,
direction = "right",
action = hyd.dsp.focus({ direction = "right" }),
})The function action runs once when the gesture ends. It uses exactly the same group-aware focus behavior as the keyboard bindings: a horizontal swipe selects the adjacent tab inside a group, then falls through to spatial focus at the group edge. It can therefore focus another group or monitor instead of wrapping around the current groupbar. The example maps physical left/right motion to the same focus direction; swap the two action directions if content-style movement feels more natural.
Choose the finger count and optional mods for your own configuration. Hyprland
matches the finger count and exact modifier mask, then resolves overlapping
directions in registration order. Specific left and right gestures with the
same fingers/modifiers can coexist. An earlier broad horizontal or swipe
gesture (commonly used for workspace switching) can shadow a later specific
gesture; registering the specific gesture first gives it precedence over a later
broad one. Avoid relying on order when possible: remove the overlap or use a
different finger count or modifier.
hl.gesture({
fingers = 3,
direction = "left",
mods = "SUPER",
action = hyd.dsp.focus({ direction = "left" }),
})The simple function form receives no cancellation metadata and is invoked even
for a cancelled ending. If cancellation matters, the v0.56 runtime also accepts a
lifecycle callback table; its finish callback receives the end event:
local focus_left = hyd.dsp.focus({ direction = "left" })
hl.gesture({
fingers = 4,
direction = "left",
action = {
finish = function(event)
if not event.cancelled then
focus_left()
end
end,
},
})The v0.56 Lua stub declares action as string|function even though the runtime
accepts { start?, update?, finish? }, so LuaLS may warn about this advanced form.
All dispatchers live under hyd.dsp. They validate their arguments at
bind time; an invalid argument is logged as an error and replaced with a
no-op so Hyprland's bind call doesn't crash.
Drop-in replacement for hl.dsp.focus(args). Use it everywhere you
would use hl.dsp.focus — including bindings that have nothing to do
with groups — so that the group-aware behaviour kicks in whenever it
applies and you don't have to remember which dispatcher goes where.
Two shapes:
{ direction = "left" | "right" | "up" | "down" }— group-aware focus.directionmust be the only key. Inside a multi-member group, horizontal moves to the previous/next tab; at the edge of the group (or in vertical) it delegates to Hyprland's spatial focus.- Anything else — passthrough to
hl.dsp.focus(args). Ifargs.workspaceis set and hyprsplit is loaded, the workspace selector is translated through hyprsplit before dispatch. This means{ workspace = 3 }resolves to the third workspace on the current monitor, not workspace id 3 globally.
Unlike hyd.dsp.focus, this is not a full replacement for
hl.dsp.window.move. It implements three specific shapes — and only
these three — chosen for moving windows in and out of tab groups. For
any other move dispatch (e.g. active, cursor, tag = ...) keep
using hl.dsp.window.move directly.
Exactly one of direction / workspace / monitor is required.
{ direction = "left" | "right" | "up" | "down" }— group-aware move. Within the group, reorders by one slot. At the edge, attempts a native directional merge when a tiled neighbour exists on the same workspace. A locally locked source is always a no-op, regardless ofbinds.ignore_group_lock. If native policy rejects the target, source membership stays unchanged (a previously standalone target may already have become a singleton during native target resolution). With no neighbour, the active member of a multi-member group is ejected directionally and immediately wrapped in a focused singleton group, so the split survives later reconciliation and can accept newly opened windows. A sole member stays unchanged.{ workspace = sel, follow? = bool }— directly remove the active member from its group, thenhl.dsp.window.moveit tosel.selis run through hyprsplit translation when hyprsplit is loaded. This explicit cross-target detach ignores local/global group locks and does not emit Hyprland'smoveoutofgroupIPC event. If removal cannot be confirmed, the destination move is aborted.{ monitor = sel, follow? = bool }— apply the same validated direct detach, thenhl.dsp.window.movetosel.
follow controls whether focus follows the moved window. When omitted,
Hyprland's default applies.
hyprdeck transparently cooperates with
hyprsplit (per-monitor workspace
sets). When hyprsplit is loaded, any workspace selector passed to
hyd.dsp.focus or hyd.dsp.window.move is run through
hs.get_workspace_string first, so the same { workspace = 3 } binding
means "the third workspace on this monitor" instead of "workspace id 3".
Detection is automatic and survives module-name renames (the lookup is
duck-typed against package.loaded). If hyprsplit is not loaded the
selectors are passed through unchanged, so you do not need to gate
anything in your config.
There is no required load order — require("hyprsplit") and
require("hyprdeck") may appear in either order.
- hy3 — i3/sway-style tree tiling for Hyprland. Different model: explicit horizontal/vertical splits and tabbed/stacked containers as first-class layouts, implemented as a native C++ plugin.
- hyprland-go monocle script — external Go program that keeps one window visible at a time by manipulating Hyprland over IPC.
- hyprland-go — Go bindings for Hyprland's IPC, useful if you'd rather script this behaviour from outside the compositor.
The project lives at https://github.com/chpock/hyprdeck.
- Source code: github.com/chpock/hyprdeck
- Bug reports and feature requests: github.com/chpock/hyprdeck/issues
When filing a bug, please attach the Hyprland log captured with
log_level = "trace" — it makes event ordering visible and is usually
the difference between a reproducible report and guesswork.
BSD 3-Clause. See LICENSE.