Per-monitor Space switching for macOS — with animation.
Switch Spaces on any display using independent keyboard shortcuts, regardless of where your mouse or focus is. Unlike tools that use the private SetCurrentSpace API (which teleports instantly), SpaceSwitcher triggers macOS's native slide animation by redirecting synthetic keyboard events through a focus proxy.
- You press a custom shortcut (e.g.
Ctrl+Option+←for your secondary display). - SpaceSwitcher intercepts it via a global
CGEventTap. - The mouse cursor is warped to the target display and the event's location is updated to match.
- The event's modifiers are stripped down to plain
Ctrl+Arrow. - macOS processes the modified event → animated slide transition on the correct display.
- After a brief settle delay, the cursor is restored to its original position.
This cursor-warp approach gives you the native macOS animation while letting you target any display from any keyboard shortcut.
- Animated switching — uses macOS's own slide transition, not an instant teleport
- Per-monitor shortcuts — assign different key combos to each display
- Ignore monitors — set
"enabled": falsefor displays with only one Space - Wrap-around — optionally wrap from the last Space back to the first (instant, since macOS doesn't natively wrap)
- Focus restoration — optionally re-focus the previously active app after switching
- Zero UI — runs as a background process with no Dock icon
- JSON config — easy to edit, auto-created on first launch
- macOS 13 (Ventura) or later (tested through macOS 15)
- Xcode Command Line Tools (for
swift build) - Accessibility permission granted to the built binary
# 1. Build
cd SpaceSwitcher
swift build -c release
# 2. First run — creates default config and prompts for Accessibility access
.build/release/SpaceSwitcher
# 3. Grant Accessibility permission
# System Settings → Privacy & Security → Accessibility
# Add the SpaceSwitcher binary (or Terminal if running from there)
# 4. Edit the config to match your setup
nano ~/.config/spaceswitcher/config.json
# 5. Run
.build/release/SpaceSwitcher.build/release/SpaceSwitcher --list-displaysExample output:
Connected displays:
[0] Built-in Retina Display
UUID: Main
Spaces: 3 desktop + 0 fullscreen
Current space ID: 5
Desktop spaces: →5 6 7
[1] LG UltraFine
UUID: 37D8832A-2D66-02CA-B9F7-8F30A301B230
Spaces: 2 desktop + 1 fullscreen
Current space ID: 12
Desktop spaces: →12 13
[2] Dell U2723QE
UUID: A1B2C3D4-...
Spaces: 1 desktop + 0 fullscreen
Current space ID: 18
Desktop spaces: →18
Display 2 has only one Space — set "enabled": false for it in your config.
Config lives at ~/.config/spaceswitcher/config.json. A default is created on first run, or generate one explicitly with --init-config.
{
"monitorBindings": [
{
"displayIndex": 0,
"label": "Main Display",
"enabled": true,
"prevSpaceShortcut": { "key": "left", "modifiers": ["control"] },
"nextSpaceShortcut": { "key": "right", "modifiers": ["control"] }
},
{
"displayIndex": 1,
"label": "Ultrawide",
"enabled": true,
"prevSpaceShortcut": { "key": "left", "modifiers": ["control", "option"] },
"nextSpaceShortcut": { "key": "right", "modifiers": ["control", "option"] }
},
{
"displayIndex": 2,
"label": "Vertical Monitor",
"enabled": false,
"prevSpaceShortcut": { "key": "left", "modifiers": ["control", "shift"] },
"nextSpaceShortcut": { "key": "right", "modifiers": ["control", "shift"] }
}
],
"wrapAround": false,
"restoreFocus": false
}| Field | Description |
|---|---|
displayIndex |
Monitor number from --list-displays (0-based) |
label |
Human-readable name (for logging only) |
enabled |
Set false to ignore this monitor entirely |
prevSpaceShortcut |
Key combo to switch left (previous Space) |
nextSpaceShortcut |
Key combo to switch right (next Space) |
wrapAround |
If true, going past the last Space wraps to the first (instant, no animation) |
restoreFocus |
If true, re-focus the previously active app after switching. If false, focus follows the switched display. |
Arrows: left, right, up, down
Letters: a–z
Numbers: 0–9
Function keys: f1–f12
Special: space, tab, escape, delete, return
control / ctrl, shift, option / alt, command / cmd
Since SpaceSwitcher works by posting synthetic Ctrl+Arrow events that macOS processes natively, the built-in "Move left/right a space" shortcuts must stay enabled in:
System Settings → Keyboard → Keyboard Shortcuts → Mission Control
If you've disabled them, re-enable them — they're what actually triggers the animation.
Your custom per-display shortcuts (e.g. Ctrl+Option+Arrow) can be anything you want. SpaceSwitcher intercepts those, then redirects a Ctrl+Arrow to the correct display.
Also recommended — uncheck this:
System Settings → Desktop & Dock → Mission Control → "Automatically rearrange Spaces based on most recent use"
Create ~/Library/LaunchAgents/com.spaceswitcher.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.spaceswitcher</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/SpaceSwitcher</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/spaceswitcher.log</string>
<key>StandardErrorPath</key>
<string>/tmp/spaceswitcher.log</string>
</dict>
</plist>sudo cp .build/release/SpaceSwitcher /usr/local/bin/
launchctl load ~/Library/LaunchAgents/com.spaceswitcher.plistSpaceSwitcher [OPTIONS]
--help, -h Show help
--verbose, -v Debug logging
--list-displays Show displays and spaces, then exit
--init-config Write default config and exit
"Could not create CGEventTap"
→ Grant Accessibility access in System Settings → Privacy & Security → Accessibility.
"CGSCopyManagedDisplaySpaces returned nil"
→ The private API symbol may have been renamed in your macOS version. File an issue with your macOS version number.
Spaces switch on wrong monitor
→ Run --list-displays to verify index-to-monitor mapping. Indices can change when monitors are connected/disconnected.
Animation feels delayed
→ There is a 0.7s cursor-restore delay in HotkeyManager.swift that lets the space-switch animation settle before warping the cursor back to its original position. This delay is not user-configurable currently.
Wrap-around has no animation
→ By design. macOS's native Ctrl+Arrow doesn't wrap, so wrap-around uses the direct private API (instant switch). Regular left/right switching always animates.
main.swift Entry point, CLI, config loading
Config.swift JSON config parsing + shortcut key-code mapping
HotkeyManager.swift CGEventTap — intercepts shortcuts, dispatches switches
SpaceManager.swift Queries displays/spaces, posts synthetic events
HelperWindow.swift 1×1 invisible NSWindow (legacy, currently unused)
PrivateAPIs.swift Dynamic loading of SkyLight/CGS private symbols
DisplayInfo.swift Data structures for displays and spaces
Logger.swift Timestamped console logging
MIT