@GitHub_Daily: Mac's window management starts to get messy with seven or eight windows open; finding things relies on scrolling through Mission Control. PaperWM spoon takes a different approach, arranging windows in a flat, scrollable long strip instead of overlapping them. New windows are placed to the side without blocking others, and keyboard shortcuts allow scrolling left and right to switch between them, like flipping through a film reel. GitH…

X AI KOLs Timeline Tools

Summary

PaperWM spoon is a Hammerspoon plugin that provides tiling window management for Mac, arranging windows on a scrollable strip and operated via keyboard shortcuts, aiming to improve multi-window workflows.

Mac's window management becomes chaotic with seven or eight windows open; finding things depends on scrolling through Mission Control. PaperWM spoon takes a different approach, laying out windows on a scrollable long strip instead of overlapping them. New windows are placed to the side, not obstructing others, and keyboard shortcuts enable left-right scrolling to switch between them, like flipping through a film reel. GitHub: http://github.com/mogenson/PaperWM.spoon… It is a plugin for Hammerspoon, a Mac automation tool, where all keyboard shortcuts are customizable. Window widths can be cycled through preset sizes, two windows can be split-screen with one key, and it supports multiple monitors and desktops. This scrolling tiling approach has been popular in the Linux desktop community for many years, so bringing it to Mac is quite novel. For those accustomed to keyboard workflows and dissatisfied with Mac's native window management, this can be used to revamp their workspace.
Original Article
View Cached Full Text

Cached at: 08/19/26, 10:47 PM

Mac window management gets messy once you open seven or eight windows—finding anything requires digging through Mission Control. PaperWM.spoon takes a different approach: windows no longer overlap. Instead, they are all tiled on a scrollable horizontal strip. New windows just appear next to the others without covering anyone. You can scroll left and right with keyboard shortcuts, like flipping through a film reel.

GitHub: http://github.com/mogenson/PaperWM.spoon…

It’s a plugin for the Mac automation tool Hammerspoon, and all keyboard shortcuts are fully customizable. You can cycle through preset window widths, split the screen with one shortcut, and it supports multiple monitors and desktops. This scrollable tiling concept has been popular in the Linux desktop world for years, so bringing it to Mac feels quite fresh. If you prefer keyboard-driven workflows and find Mac’s native window management too basic, this tool can help you transform your workspace.


mogenson/PaperWM.spoon

Source: https://github.com/mogenson/PaperWM.spoon

PaperWM.spoon

Tiled scrollable window manager for macOS. Inspired by PaperWM. Spoon plugin for Hammerspoon macOS automation app.

Demo

https://user-images.githubusercontent.com/900731/147793584-f937811a-20aa-4282-baf5-035e5ddc12ea.mp4

Installation

  1. Clone into the Hammerspoon Spoons directory:
    git clone https://github.com/mogenson/PaperWM.spoon ~/.hammerspoon/Spoons/PaperWM.spoon
    
  2. Open System PreferencesDesktop & Dock. Scroll to the bottom to Mission Control, then uncheck “Automatically rearrange Spaces based on most recent use” and check “Displays have separate Spaces”.

Install with SpoonInstall

hs.loadSpoon("SpoonInstall")

spoon.SpoonInstall.repos.PaperWM = {
  url = "https://github.com/mogenson/PaperWM.spoon",
  desc = "PaperWM.spoon repository",
  branch = "release",
}

spoon.SpoonInstall:andUse("PaperWM", {
  repo = "PaperWM",
  config = {
    screen_margin = 16,
    window_gap = 2,
  },
  start = true,
  hotkeys = {
    -- <see below>
  },
})

Usage

Add the following to your ~/.hammerspoon/init.lua:

PaperWM = hs.loadSpoon("PaperWM")
PaperWM:bindHotkeys({
  -- switch to a new focused window in tiled grid
  focus_left = {{"alt", "cmd"}, "left"},
  focus_right = {{"alt", "cmd"}, "right"},
  focus_up = {{"alt", "cmd"}, "up"},
  focus_down = {{"alt", "cmd"}, "down"},

  -- switch windows by cycling forward/backward
  -- (forward = down or right, backward = up or left)
  focus_prev = {{"alt", "cmd"}, "k"},
  focus_next = {{"alt", "cmd"}, "j"},

  -- move windows around in tiled grid
  swap_left = {{"alt", "cmd", "shift"}, "left"},
  swap_right = {{"alt", "cmd", "shift"}, "right"},
  swap_up = {{"alt", "cmd", "shift"}, "up"},
  swap_down = {{"alt", "cmd", "shift"}, "down"},

  -- position and resize focused window
  center_window = {{"alt", "cmd"}, "c"},
  anchor_window_left = {{"ctrl", "alt", "cmd"}, ","},
  anchor_window_right = {{"ctrl", "alt", "cmd"}, "."},
  full_width = {{"alt", "cmd"}, "f"},
  cycle_width = {{"alt", "cmd"}, "r"},
  reverse_cycle_width = {{"ctrl", "alt", "cmd"}, "r"},
  cycle_height = {{"alt", "cmd", "shift"}, "r"},
  reverse_cycle_height = {{"ctrl", "alt", "cmd", "shift"}, "r"},

  -- increase/decrease width
  increase_width = {{"alt", "cmd"}, "l"},
  decrease_width = {{"alt", "cmd"}, "h"},

  -- move focused window into / out of a column
  slurp_in = {{"alt", "cmd"}, "i"},
  barf_out = {{"alt", "cmd"}, "o"},

  -- split screen focused window with left window
  split_screen = {{ "alt", "cmd" }, "s"},

  -- move the focused window into / out of the tiling layer
  toggle_floating = {{"alt", "cmd", "shift"}, "escape"},

  -- raise all floating windows on top of tiled windows
  focus_floating = {{"alt", "cmd", "shift"}, "f"},

  -- focus the first / second / etc window in the current space
  focus_window_1 = {{"cmd", "shift"}, "1"},
  focus_window_2 = {{"cmd", "shift"}, "2"},
  focus_window_3 = {{"cmd", "shift"}, "3"},
  focus_window_4 = {{"cmd", "shift"}, "4"},
  focus_window_5 = {{"cmd", "shift"}, "5"},
  focus_window_6 = {{"cmd", "shift"}, "6"},
  focus_window_7 = {{"cmd", "shift"}, "7"},
  focus_window_8 = {{"cmd", "shift"}, "8"},
  focus_window_9 = {{"cmd", "shift"}, "9"},

  -- focus the leftmost / rightmost window in the current space
  focus_window_first = {{"cmd", "shift"}, "home"},
  focus_window_last = {{"cmd", "shift"}, "end"},

  -- switch to a new Mission Control space
  switch_recent_space = {{"ctrl", "alt", "cmd"}, "6"},
  switch_space_l = {{"alt", "cmd"}, ","},
  switch_space_r = {{"alt", "cmd"}, "."},
  switch_space_1 = {{"alt", "cmd"}, "1"},
  switch_space_2 = {{"alt", "cmd"}, "2"},
  switch_space_3 = {{"alt", "cmd"}, "3"},
  switch_space_4 = {{"alt", "cmd"}, "4"},
  switch_space_5 = {{"alt", "cmd"}, "5"},
  switch_space_6 = {{"alt", "cmd"}, "6"},
  switch_space_7 = {{"alt", "cmd"}, "7"},
  switch_space_8 = {{"alt", "cmd"}, "8"},
  switch_space_9 = {{"alt", "cmd"}, "9"},

  -- move focused window to a new space and tile
  move_window_l = {{ "ctrl", "alt", "cmd" }, "left"},
  move_window_r = {{ "ctrl", "alt", "cmd" }, "right"},
  move_window_u = {{ "ctrl", "alt", "cmd" }, "up"},
  move_window_d = {{ "ctrl", "alt", "cmd" }, "down"},
  move_window_1 = {{"alt", "cmd", "shift"}, "1"},
  move_window_2 = {{"alt", "cmd", "shift"}, "2"},
  move_window_3 = {{"alt", "cmd", "shift"}, "3"},
  move_window_4 = {{"alt", "cmd", "shift"}, "4"},
  move_window_5 = {{"alt", "cmd", "shift"}, "5"},
  move_window_6 = {{"alt", "cmd", "shift"}, "6"},
  move_window_7 = {{"alt", "cmd", "shift"}, "7"},
  move_window_8 = {{"alt", "cmd", "shift"}, "8"},
  move_window_9 = {{"alt", "cmd", "shift"}, "9"},

  -- refresh/retile windows (forcedly)
  refresh_windows = { { "alt", "cmd", "shift" }, "r" },
  refresh_windows_forcedly = { { "alt", "cmd", "shift" }, "t" },
})
PaperWM:start()

Feel free to customize hotkeys or use PaperWM:bindHotkeys(PaperWM.default_hotkeys) for defaults.

PaperWM actions are also available for manual keybinding. The PaperWM.actions.actions() function will return a table of action names and functions to call. For example, the following config uses a hyper key and a modal layer to navigate windows with the h/j/k/l keys, like vim:

PaperWM = hs.loadSpoon("PaperWM")
PaperWM:bindHotkeys(PaperWM.default_hotkeys)

-- use ⌘ Enter as hyper key to enter modal layer, press Escape to exit
local modal = hs.hotkey.modal.new({ "cmd" }, "return")
local actions = PaperWM.actions.actions()
modal:bind({}, "h", nil, actions.focus_left)
modal:bind({}, "j", nil, actions.focus_down)
modal:bind({}, "k", nil, actions.focus_up)
modal:bind({}, "l", nil, actions.focus_right)
modal:bind({}, "escape", function() modal:exit() end)
PaperWM:start()

PaperWM:start() will begin automatically tiling new and existing windows. PaperWM:stop() will release control over windows.

Set PaperWM.window_gap to the number of pixels between windows and screen edges. This can be a single number for all sides, or a table specifying top, bottom, left, and right gaps individually. For example:

-- 10px gap on all sides
PaperWM.window_gap = 10

-- or specific gaps per side
PaperWM.window_gap = { top = 10, bottom = 8, left = 12, right = 12 }

Third-party tools like Sketchybar can be used to create custom status bars and/or dock. Set PaperWM.external_bar to a table specifying top, bottom in number of pixels of your bar and dock to ensure consistent window placement on displays with and without a “notch”. For example:

-- Add 40px offset for an external status bar
PaperWM.external_bar = {top = 40}

-- or, add 20px offset for an external status bar and 40px offset for an external dock
PaperWM.external_bar = {top = 20, bottom = 40}

Configure the PaperWM.window_filter to set which apps and screens are managed. For example:

-- ignore a specific app
PaperWM.window_filter:rejectApp("iStat Menus Status")

-- ignore a specific window of an app
PaperWM.window_filter:setAppFilter("iTunes", { rejectTitles = "MiniPlayer" })

-- list of screens to tile (use % to escape string match characters, like -)
PaperWM.window_filter:setScreens({ "Built%-in Retina Display" })

-- restart for new window filter to take effect
PaperWM:start()

Set PaperWM.center_mouse to control whether the mouse cursor is centered on the screen after switching spaces. Default is true. Example:

-- disable mouse centering when switching spaces
PaperWM.center_mouse = false

Set PaperWM.infinite_loop_window to true to enable wrapping focus at the edges of the window list. When enabled, focusing left from the leftmost window wraps to the rightmost, and focusing up from the topmost window wraps to the bottommost (and vice versa). Default is false. Example:

-- enable infinite loop scrolling for focus left/right/up/down
PaperWM.infinite_loop_window = true

Set PaperWM.window_ratios to the ratios to cycle window widths and heights through. For example:

PaperWM.window_ratios = {
  1/3,
  1/2,
  2/3,
}

Set PaperWM.default_width to set the width of newly added windows as a ratio of the screen’s width (e.g., 0.5 means half the screen width):

PaperWM.default_width = 0.5

Set PaperWM.app_widths to control default window widths per app. Keys can be application names or bundle IDs, and values are width ratios (see PaperWM.default_width). app_widths overrides default_width for matching applications.

PaperWM.app_widths = {
  ["Google Chrome"] = 0.5,
  ["com.apple.Safari"] = 0.75,
}

Some applications report windows as non-maximizable even though those windows can still be tiled correctly. Set PaperWM.allow_non_maximizable_window to opt in specific windows:

PaperWM.allow_non_maximizable_window = function(window)
  local app = window:application()
  return app and app:bundleID() == "net.imput.helium"
end

Set PaperWM.preserve_app_focus to control whether to preserve the focused app when switching spaces. Default is false. For example:

-- preserve app focus when switching spaces
PaperWM.preserve_app_focus = true

Set PaperWM.move_window_keep_space to stay on the current space after moving a window to another space with move_window_1-move_window_9, instead of switching to follow the window. Default is false. For example:

-- stay on the current space after moving a window to another space
PaperWM.move_window_keep_space = true

Smooth Scrolling

https://github.com/user-attachments/assets/6f1c4659-0ca8-4ba1-a181-8c1c6987e8ef

PaperWM.spoon can scroll windows left or right by swiping fingers horizontally across the trackpad. Set the number of fingers (e.g. 2, 3, or 4) and, optionally, a gain to adjust the sensitivity:

-- number of fingers to detect a horizontal swipe, set to 0 to disable (the default)
PaperWM.swipe_fingers = 0

-- increase this number to make windows move farther when swiping
-- use a negative value to reverse swipe direction
PaperWM.swipe_gain = 1.0

Inspired by ScrollDesktop.spoon.

Mouse Dragging

https://github.com/user-attachments/assets/61a0afda-93e6-41b3-963c-7681a4bbe7c7

Click and drag a window with the mouse while holding the PaperWM.drag_window hotkey to slide and reposition all the windows on a space. Click on a window with the PaperWM.lift_window hotkey held to lift it up, drag to move the window, and release the mouse to drop it in a new tiled location. This is useful for moving a window to a new screen.

-- set to a table of modifier keys to enable window dragging, default is nil
PaperWM.drag_window = { "alt", "cmd" }`

-- set to a table of modifier keys to enable window lifting, default is nil
PaperWM.lift_window = { "alt", "cmd", "shift" }`

Mouse Scrolling

Spin the mouse scroll wheel while holding the PaperWM.scroll_window hotkey to slide all windows on a space left or right. Release the hotkey to stop. Change PaperWM.scroll_gain to a positive or negative number to adjust the direction and sensitivity.

-- set to a table of modifier keys to enable window scrolling, default is nil
PaperWM.scroll_window = { "alt", "cmd" }`

-- increase move windows further when scrolling, invert to change direction
PaperWM.scroll_gain = 10.0

Limitations

macOS does not allow a window to be moved fully off-screen. Windows that would be tiled off-screen are placed in a margin on the left and right edge of the screen. They are still visible and clickable. It’s difficult to detect when a window is dragged from one space or screen to another. Use the move_window_N commands to move windows between spaces and screens. Arrange screens vertically to prevent windows from bleeding into other screens. Use WarpMouse.spoon to simulate side-by-side screens.

Add-ons

The following spoons complement PaperWM.spoon nicely.

  • ActiveSpace.spoon
    Show active and layout of Mission Control spaces in the menu bar.

  • WarpMouse.spoon
    Move mouse cursor between screen edges to simulate side-by-side screens.

  • Swipe.spoon
    Perform actions when trackpad swipe gestures are recognized.
    Here’s an example config to change PaperWM.spoon focused window:

-- focus adjacent window with 3 finger swipe
local actions = PaperWM.actions.actions()
local current_id, threshold

Swipe = hs.loadSpoon("Swipe")
Swipe:start(3, function(direction, distance, id)
  if id == current_id then
    if distance > threshold then
      threshold = math.huge -- trigger once per swipe
      -- use "natural" scrolling
      if direction == "left" then
        actions.focus_right()
      elseif direction == "right" then
        actions.focus_left()
      elseif direction == "up" then
        actions.focus_down()
      elseif direction == "down" then
        actions.focus_up()
      end
    end
  else
    current_id = id
    threshold = 0.2 -- swipe distance > 20% of trackpad size
  end
end)
  • FocusMode.spoon
    Helps you stay in flow by dimming everything except what you’re working on.

Contributing

Contributions are welcome! Here are a few preferences:

  • Global variables are PascalCase (e.g. PaperWM)
  • Local variables are snake_case (e.g. local focused_window)
  • Function names are camelCase (e.g. function windowEventHandler())
  • Use `` where possible
  • Create a local copy when deeply nested members are used often (e.g. local Watcher = hs.uielement.watcher)

Code format checking and linting is provided by lua-language-server for commits and pull requests. Run lua-language-server --check . locally before committing.

Busted is used for unit testing. Run busted from the repo root to run tests locally.

Similar Articles

@wsl8297: Working on a Mac often means juggling a dozen open windows. Finding the specific one you need is often more exhausting than the actual work. Recently, I came across Paneru, an open-source window manager with a straightforward concept: it uses a horizontal "scroll bar" to organize windows, making switching between them orderly and intuitive. It’s somewhat like Niri on Linux...

X AI KOLs Timeline

Paneru is an open-source tiling window manager for macOS that features a unique horizontal scroll bar layout. Designed to solve the chaos of managing multiple windows, it draws inspiration from Niri on Linux.

@wsl8297: While browsing GitHub, I stumbled upon a real powerhouse: niri. It directly bypasses the age-old desktop problem of "windows getting squished when there are too many" and completely reimagines window management. niri adopts a scrollable tiling approach: windows are arranged in columns on an "infinitely extending" horizontal strip. When you open a new window, it doesn't forcefully compress existing ones; on multi-monitor setups, each screen has its own independent workspaces and window strips, clean and efficient.

X AI KOLs Timeline

This article introduces the open-source project niri, a new Wayland-based desktop compositor that avoids traditional window crowding issues through a scrollable tiling layout, and supports features such as dynamic workspaces, independent multi-monitor management, and custom shader animations.

@geekbb: Great tool, a macOS sidebar utility for the Ghostty terminal that allows you to quickly create, switch between, and arrange terminal sessions. Smoothly control windows via Ghostty AppleScript and Accessibility APIs, enabling direct jumps to the Space containing a specific session from a floating panel. S…

X AI KOLs Timeline

Rig is a macOS sidebar utility developed for the Ghostty terminal, supporting rapid creation, switching, and arrangement of terminal session windows via AppleScript and Accessibility APIs.

@LuBtc888: Seriously, I barely touch tmux for multi-terminal management anymore. With http://herdr.dev and http://muxy.app, these two tools kick tmux's cumbersome configuration out of the game. No need to memorize complex shortcuts, no need to fiddle with config files—ready to use out of the box, a blessing for the lazy. Productivity tools...

X AI KOLs Timeline

Introduces two terminal multi-window management tools, Herdr and Muxy. Herdr is a Rust-based native terminal proxy runtime that supports persistence, mouse operations, and proxy state management, serving as a tmux alternative.

@I00B6: Don't rush to renew Mac software or buy various apps. Recently discovered an open-source menu bar tool that incorporates functions of many common paid tools: volume control, system monitor, window switcher, file staging, app uninstaller, anti-sleep... One app covers all, free, open-source, no ads, runs locally. The open-source ecosystem is now...

X AI KOLs Timeline

Introducing an open-source Mac menu bar tool Vorssaint, which integrates multiple common paid features like volume control, system monitoring, window switching, etc. Free, no ads, and runs locally.