A scrolling window manager for macOS. Windows live in columns on a horizontal strip (PaperWM-style); navigation teleports the viewport instantly.
One permission: Accessibility. No Screen Recording, no Input Monitoring, no private APIs, no daemons.
ScrollWM is built around a "never break the desktop" rule:
- Dormant by default. Launching it does nothing to your windows. Management starts only when you explicitly Arrange.
- Exact restore. Every window's original position and size is captured before the first move. Release / Quit puts everything back exactly.
- Crash-proof. Original frames are persisted to
~/Library/Application Support/ScrollWM/restore.jsonwhile managing. If ScrollWM crashes or iskill -9ed, the next launch restores all windows automatically. - Panic switch.
ctrl+opt+esctoggles arrange/release at any time.
ScrollWM is a single menu-bar app. The one-line installer below is the
recommended path; all options land the same ScrollWM.app in ~/Applications
(or /Applications).
curl -fsSL https://raw.githubusercontent.com/1jehuang/scrollwm/main/scripts/web-install.sh | bashDownloads the latest release (.zip, or the .dmg if that's all that's
published), strips the Gatekeeper quarantine, installs to ~/Applications, and
launches it. No sudo, no system files touched. Re-run the same command anytime
to update in place.
Other ways to install
Homebrew
brew install --cask 1jehuang/scrollwm/scrollwm(That auto-taps this repo. If you prefer to tap first:
brew tap 1jehuang/scrollwm https://github.com/1jehuang/scrollwm then
brew install --cask scrollwm.)
Download the app
Grab ScrollWM-<version>.dmg (or .zip) from the
latest release, drag
ScrollWM.app to Applications, then open it.
First open of a downloaded build: the app is ad-hoc signed (not notarized), so macOS may say it "cannot be opened." Right-click the app → Open → Open, just once. (The curl and Homebrew installs above handle this for you.)
ScrollWM shows a one-step onboarding window explaining its single permission and opens the right Settings pane for you. Flip the Accessibility switch for ScrollWM and the app continues automatically (no relaunch): on that first grant it arranges your current windows into the strip, so its very first act tidies the desktop with zero extra clicks. After that it's dormant until you Arrange again. If permission is already granted from a previous install, ScrollWM skips the onboarding entirely and starts silently (it never asks when it doesn't need to).
Run from Applications. If you open ScrollWM straight from a download, the Desktop, or a mounted disk image, macOS runs it from a temporary, read-only copy (App Translocation) where the Accessibility grant can never stick — you flip the switch and nothing happens. ScrollWM detects this on first launch and offers to move itself into your Applications folder and reopen from there, so you grant Accessibility exactly once. The curl/Homebrew installs already place it in
~/Applications, so they skip this entirely.
Stuck? The onboarding window has Show in Finder (drag the app into the Accessibility list if it isn't there) and Copy setup steps for my AI assistant, which puts plain instructions on your clipboard for any assistant you already use. Both are optional escape hatches, never a dependency.
Requires Xcode (or the Swift toolchain) on macOS 14+.
git clone https://github.com/1jehuang/scrollwm
cd scrollwm
./scripts/install.sh # build + install to ~/Applications
# or: ./scripts/install.sh --universal /ApplicationsScrollWM updates itself, automatically. Installed app bundles check GitHub Releases in the background (every 24h by default, plus shortly after launch) and, when a newer version is published, download it, verify its SHA-256, restore your windows, replace the app atomically in place, and relaunch into the new version. It is careful by design:
- It never disrupts an active session: if you're managing windows, the verified update is staged and applied the next time you quit, so your arranged strip is never yanked away mid-session.
- It never silently breaks itself: before swapping, it checks whether macOS will keep ScrollWM's Accessibility grant (that depends on the build's code signature). If the grant would reset, it asks first and guides the one-time re-enable instead of leaving a window manager that can't move windows.
- It won't fight Homebrew (
brew upgrade --cask scrollwminstead) or loop on a failed install (a broken release is retried a bounded number of times, then it falls back to a prompt).
Check on demand anytime from the menu bar (Check for Updates…) or the CLI:
scrollwm update # report whether a newer release exists
scrollwm update --install # download + verify + apply it, then relaunchTune it in the config file's update block: enabled (background check on/off),
automatic (silently install vs. prompt first, silent being the default),
checkIntervalHours, and allowPrerelease. Set "automatic": false to be asked
before each update, or "enabled": false to opt out entirely (manual checks
still work). Other ways to update:
- Installed via curl: re-run the one-line command.
- Installed via Homebrew:
brew upgrade --cask scrollwm. - Built from source:
./scripts/update.sh(rebuild, reinstall in place, relaunch).
Source builds are ad-hoc signed by default, so macOS sees each rebuild as a new app and drops the Accessibility grant. To keep the permission across local rebuilds, create a stable self-signed identity once:
./scripts/setup-signing.sh # one-time: makes a local code-signing cert
./scripts/update.sh # now installs signed with the stable identityAfter granting Accessibility one more time post-setup, future update.sh runs
keep the permission (the app's signing identity no longer changes per build).
If you have an Apple Developer ID Application certificate installed, the scripts prefer it automatically (over the self-signed cert and ad-hoc): local installs are then signed with the same identity used for notarized releases, so the Accessibility grant persists and you only manage one certificate. See docs/SIGNING.md.
Notarized releases open with no Gatekeeper warning. This needs a paid Apple Developer account and a "Developer ID Application" certificate; without one the pipeline still works and falls back to ad-hoc (the cask strips quarantine).
make notary-setup # one-time: guided Developer ID + notary setup
make release # build + sign + notarize + staple + cask
make release-publish # ...and upload the GitHub Release (needs gh)make release wraps scripts/release.sh, which runs build → notarize → cask in
order and degrades gracefully when a cert is missing. CI does the same on a v*
tag when the signing secrets are configured (see the comments in
.github/workflows/release.yml). Full setup, including the one-time
notarytool store-credentials, is in docs/SIGNING.md.
brew uninstall --cask scrollwm # if installed via Homebrew
# or, for curl/source installs:
curl -fsSL https://raw.githubusercontent.com/1jehuang/scrollwm/main/scripts/uninstall.sh | bash
# add --purge to also delete ~/Library/Application Support/ScrollWMThen remove ScrollWM from System Settings → Privacy & Security → Accessibility.
Default keys (all rebindable in the config file, see below):
| Control | Action |
|---|---|
| menu bar icon → Arrange | adopt current-Space windows into the strip |
⌃⌥← / ⌃⌥→ |
focus previous/next column |
⌃⌥1..⌃⌥9 |
jump to column N |
⌘H / ⌘L |
focus left / right column |
⌘⇧H / ⌘⇧L |
move focused column left / right |
⌥1..⌥4 or ⌘1..⌘4 |
set focused column width to 25% / 50% / 75% / 100% |
⌘Q |
close focused window |
⌃⌥esc |
toggle arrange/release |
| menu bar icon → window | jump to that window |
| menu → How to Use ScrollWM… | open the in-app tutorial |
| menu → Release | restore all windows, go dormant |
| menu → Quit | restore all windows and exit |
A first run pops the tutorial automatically. The width/focus/move/close keys
are only active while managing, and are torn down on Release so the desktop
behaves normally (⌘Q quits apps, ⌘H hides them) when ScrollWM is dormant.
ScrollWM ships a CLI that drives the running app from your shell or scripts
(installed on your PATH by Homebrew and install.sh). The app must be running;
arrange/toggle will launch it for you if it isn't.
scrollwm arrange # adopt current-Space windows into the strip
scrollwm release # restore all windows, go dormant
scrollwm toggle # arrange <-> release
scrollwm focus next|prev|3 # change focused column (3 is 1-based)
scrollwm move left|right # move the focused column within the strip
scrollwm move up|down # send the focused window up/down a workspace
scrollwm workspace up|down|2 # switch vertical workspace (niri-style)
scrollwm width [all] 25|50|75|100 # resize focused column (or `all`; accepts 0.0-1.0)
scrollwm close # close the focused window
scrollwm display next|main|2 # move the scrolling strip to another monitor
scrollwm focus-mode fit|centered # how the viewport follows focus
scrollwm reload # re-read the config file live
scrollwm skills # report core keybindings you've stopped using
scrollwm login on|off # start ScrollWM at login (no arg = report)
scrollwm tutorial # open the in-app cheat sheet
scrollwm update [--install] # check GitHub for a newer release (and install it)
scrollwm status # JSON snapshot of the strip
scrollwm version # app version + capabilities as JSON
scrollwm logs [--tail N|--follow] # show the app log
scrollwm quit # restore windows and quit the app
scrollwm --version # print the installed version (no running app needed)
scrollwm --help # full liststatus prints JSON so you can script against it:
scrollwm status | jq '.windowCount, .columns[].title'Every verb exits non-zero on error (e.g. not running, or not managing yet), so
it composes cleanly in scripts. Under the hood the CLI talks to the app over a
per-user Unix socket at ~/Library/Application Support/ScrollWM/control.sock —
no network, no extra permission.
All settings live in one human-editable file (the single source of truth):
~/Library/Application Support/ScrollWM/config.json
It's commented JSON. Open it from the menu (Open Config File), edit it, then
choose Reload Config — changes apply live, no relaunch. You can set the
column gap, minimum column width, width presets, focus mode (fit/centered),
and every keybinding.
Keybinding channels. Always-on keys (navigation, jump, arrange/release toggle) use permission-free Carbon global hotkeys, which cannot capture
⌘Hor⌘M(macOS reserves them; verified viaWindowLab hotkeyprobe). The while-managing keys (focus/move/width/close) ride a keyboardCGEventTap, which works with the Accessibility permission the app already holds (verified viakeytapprobe) and can bind any chord, including⌘H/⌘L. No Input Monitoring permission is required. The default config documents this inline.
The menu bar icon is a live mini-map: columns are windows, the outline is your viewport, blue is the focused window.
ScrollWM runs an independent scrolling strip per monitor (layout.multiDisplay),
each with its own columns, vertical workspaces, and viewport. The seams are
handled for you:
- Focus follows display. Click a window (or Cmd-Tab) on another monitor and navigation/width/move/workspace hotkeys switch to that monitor's strip automatically.
- Every strip in the menu bar. The menu-bar icon draws EVERY managed
monitor's strip side by side (numbered per display, the active one
highlighted), so you see all your displays at a glance instead of only the one
your keyboard is on. Toggle with
menuBar.showAllDisplays(default on; only changes the icon with more than one managed display). - Indicator on every monitor. macOS only draws the menu bar on one display,
so on the others ScrollWM floats a small mini-map of that monitor's strip at
the top-center (the active monitor's is highlighted). Toggle with
menuBar.showExternalDisplayIndicator. See it now:scrollwm's dev binary hasWindowLab indicatorprobe. - No background windows. While managing, a window that opens on a managed
monitor is tiled onto that monitor's strip, so nothing is left floating behind
it (
layout.autoTileNewWindows, default on; dialogs/panels stay floating). Release restores everything; dormant never touches anything. - Parking stays on its own display, and unplug/replug migrates the strip to a surviving monitor without stranding windows.
Sources/WindowLab/
AXSource.swift timeout-protected Accessibility wrapper
AccessibilityPermission.swift single source of truth for the AX grant
OnboardingWindow.swift first-run permission onboarding UI
AppLocation.swift relocate to ~/Applications so the AX grant sticks
Config.swift config file (settings + rebindable keys)
TutorialWindow.swift in-app tutorial / cheat sheet (config-driven)
CGWindowSource.swift WindowServer enumeration (CGWindowList)
IdentityMatcher.swift AX<->CG window fusion (PID+frame+title scoring)
TeleportEngine.swift strip layout, viewport, prioritized commits
LifecycleMonitor.swift adopt new / drop closed windows (notif + poll)
RestoreStore.swift crash-recovery frame persistence
ScrollWMApp.swift production app: controller, menu bar, signals
Hotkeys.swift Carbon global hotkeys (permission-free)
MenuBar.swift lab-mode mini-map status item
...benchmarks measurement harness (see below)
WindowLab doubles as a measurement harness. Every architectural decision
in this repo was validated against measured numbers on real hardware:
swift build
.build/debug/WindowLab probe -v # enumerate + match windows, latency
.build/debug/WindowLab bench # AX move/resize cost per window
.build/debug/WindowLab scrollbench 16 60 # real-window animation jank (headless)
.build/debug/WindowLab pan 10 8 --spawn --selftest # scroll-driven panning
.build/debug/WindowLab overlay 8 --selftest # Metal overlay + event tap
.build/debug/WindowLab capturebench 5 # SCK capture latency (needs Screen Rec)
.build/debug/WindowLab teleport --spawn --selftest # teleport e2e
.build/debug/WindowLab run --selftest # production round trip
.build/debug/WindowLab run --crashtest # crash phase (then relaunch to recover)Measured on M-series MacBook (macOS 26):
| Operation | p50 | p95 |
|---|---|---|
| AX move window | 0.4 ms | 0.6 ms |
| Full-strip teleport (8 windows) | 3.4 ms | 5.0 ms |
| 16 real windows animated @60Hz | 4.0 ms/tick | 8.0 ms (budget 16.7) |
| SCK capture age | 0.5 ms | 0.9 ms |
| IOSurface→MTLTexture | 0.01 ms | 0.05 ms |