
Bringing My Hyprland Workflow to macOS with AeroSpace and Chezmoi
My daily driver for the past year or so has been a CachyOS Linux desktop running Hyprland – a dynamic tiling Wayland compositor that, once you get the muscle memory down, makes you feel like you're piloting a spaceship. Workspaces on number keys, windows that tile themselves with smooth physics-based animations, gaps and blur effects dialed in exactly how you want them. It's the kind of setup where going back to a traditional desktop feels like wading through mud.
The problem is I also use a Mac for work. And every time I'd open it, the experience felt jarring by comparison – not because macOS is slow, but because I was reaching for keybindings that didn't exist, dragging windows around with a mouse like some kind of caveman, and generally missing the workflow I'd built on Linux. I wanted the same muscle memory, the same tiling behavior, the same shell environment – regardless of which machine I was sitting in front of. So I set out to close that gap as much as possible, and I wanted to do it with a single dotfiles repo that manages both machines. No, macOS will never be Hyprland – it's a full compositor with animation systems, blur effects, and window decorations that just aren't possible on macOS – but the workflow can get surprisingly close. This post walks through how I did it – setting up a tiling window manager, syncing dotfiles across both platforms with chezmoi, and the config tweaks that tie it all together.
AeroSpace: tiling on macOS
I want to set expectations before I get into the config. AeroSpace is a tiling window manager for macOS inspired by i3 – it is not a compositor. Hyprland gives you physics-based animations, blur, transparency, window decorations, and extensive ricing capabilities. AeroSpace intentionally provides none of that. The maintainer has explicitly said they don't care about ricing, and the project will never offer a GUI for configuration. There are forks like HyprSpace trying to bring more Hyprland-like features to macOS, but I haven't tried them. For me, the workflow is what matters – tiling, workspaces, keybindings – and those translate well.
AeroSpace also doesn't use native macOS Spaces at all. Apple doesn't provide a public API to interact with Spaces, so AeroSpace implements its own virtual workspace system by moving windows outside the visible screen area when they're not on the active workspace. This sounds hacky, but in practice it's seamless – and it means you don't have to deal with the slow, animated Space-switching that macOS forces on you.
Install it via Homebrew:
brew install --cask nikitabobko/tap/aerospaceAfter installing, you'll need to grant it accessibility permissions in System Settings. AeroSpace uses macOS accessibility APIs to move and resize windows (it only uses one private API – _AXUIElementGetWindow – everything else is public), so the OS needs you to explicitly approve that. Head to System Settings > Privacy & Security > Accessibility and toggle AeroSpace on. Fair warning: macOS can be weirdly aggressive about revoking accessibility access after system updates. If AeroSpace suddenly stops responding to keybindings one day, check that toggle first – I've had to flip it off and on again more than once. On a work Mac this can be extra fun if your IT department manages security settings.
The configuration can live in ~/.aerospace.toml or ~/.config/aerospace/aerospace.toml. I went with the XDG-compliant path since I manage everything under ~/.config/ in my dotfiles repo – just make sure you only use one location, because AeroSpace will complain if it finds both. Here's where we start making it feel like home. The key settings I landed on:
# Start automatically on login
start-at-login = true
# Spiral tiling like Hyprland's dwindle layout
enable-normalization-flatten-containers = true
enable-normalization-opposite-orientation-for-nested-containers = true
# Tiles layout for automatic tiling
default-root-container-layout = 'tiles'
default-root-container-orientation = 'auto'
# Mouse follows focus (Hyprland-like behavior)
on-focused-monitor-changed = ['move-mouse monitor-lazy-center']
on-focus-changed = ['move-mouse window-lazy-center']That opposite-orientation-for-nested-containers setting is the key one. AeroSpace uses an i3-inspired tree tiling model where every non-leaf node is a container, and containers can nest as deeply as you want. This normalization rule forces nested containers to alternate orientations, which gives you the spiral tiling pattern that Hyprland does by default with its dwindle layout. Windows alternate between horizontal and vertical splits automatically, so you get that nice cascading layout without having to manually choose split directions.
For gaps, I went with a clean minimal look:
[gaps]
inner.horizontal = 8
inner.vertical = 8
outer.left = 8
outer.bottom = 8
outer.top = [{ monitor.'^built-in.*$' = 8 }, 8]
outer.right = 8The outer.top line is worth calling out – it uses a monitor pattern match so the built-in display can have different top padding than external monitors. Useful if you're running a status bar on some screens but not others.
Now, keybindings. I originally tried using SUPER (the Command key) as my modifier, same as Hyprland. Bad idea. macOS uses Command for basically everything – copy, paste, quit, tab switching, Spotlight, you name it. I burned way too much time trying to work around conflicts before just giving up and switching to CTRL. AeroSpace defaults to alt (matching i3 convention), but neither alt nor Command worked cleanly for me. CTRL it is:
[mode.main.binding]
# Workspace navigation
ctrl-1 = 'workspace 1'
ctrl-2 = 'workspace 2'
ctrl-3 = 'workspace 3'
# ... through ctrl-9
# Move windows to workspaces
ctrl-shift-1 = 'move-node-to-workspace 1'
ctrl-shift-2 = 'move-node-to-workspace 2'
# ... and so on
# Window focus (arrow keys)
ctrl-left = 'focus left'
ctrl-down = 'focus down'
ctrl-up = 'focus up'
ctrl-right = 'focus right'
# Move windows within workspace
ctrl-shift-left = 'move left'
ctrl-shift-down = 'move down'
ctrl-shift-up = 'move up'
ctrl-shift-right = 'move right'
# Fullscreen toggle
ctrl-m = 'fullscreen'It's different from my Linux setup where I use SUPER, but my brain adapted faster than I expected. The muscle memory that matters – number keys for workspaces, arrows for focus, shift combos for moving – all carries over. Just a different modifier finger.
I also set up a resize mode, which works a lot like Hyprland's submap system – AeroSpace calls these "modes" and they work essentially the same way. You enter a named mode with a keybinding, the keys in that mode do something different, and then you press escape or enter to go back to normal. AeroSpace even shows a little indicator on screen telling you which mode you're in, which is a nice touch:
ctrl-shift-semicolon = 'mode resize'
[mode.resize.binding]
esc = 'mode main'
enter = 'mode main'
left = 'resize width -50'
right = 'resize width +50'
up = 'resize height -50'
down = 'resize height +50'
# Balance all windows evenly
b = 'balance-sizes'
# Reset the workspace layout
r = ['flatten-workspace-tree', 'mode main']If you have multiple monitors, you can also assign workspace ranges to specific displays:
[workspace-to-monitor-force-assignment]
1 = 1
2 = 1
3 = 1
4 = 3
5 = 3
6 = 3
7 = 4
8 = 4
9 = 4This gives me three workspaces per monitor, which mirrors my Hyprland setup pretty closely. One note if you go this route – AeroSpace recommends that you only use one macOS Space per monitor and let AeroSpace handle all the virtual workspace switching. You'll also want to make sure your monitors are arranged in System Settings so that every display has some free space in the bottom right or left corner, since AeroSpace uses that off-screen area to park windows that aren't on the active workspace.
One repo, two platforms
Here's the thing that actually motivated this whole project: my Mac is a work machine. I have a completely separate personal Linux desktop. And I did not want to maintain two sets of config files, but I also couldn't just dump everything into one repo without thinking about what ends up in there. Work machine means secrets, tokens, credentials – stuff that absolutely cannot land in a git repo. So before I even picked a dotfile manager, I set up a ~/.secrets/ directory that's excluded from both git and chezmoi. My .zshrc sources a ~/.secrets/github.env file if it exists, tokens stay local to each machine, and the repo stays clean. That was the non-negotiable starting point.
With that sorted, chezmoi turned out to be the right tool for the actual syncing. It's a dotfile manager that handles templating, platform detection, secrets management, and lifecycle scripting – basically a smart sync layer between a git repo and your home directory. You edit files in the repo, and chezmoi figures out what goes where based on the machine it's running on. It ships as a single statically-linked binary with no dependencies, doesn't need root access, and runs on basically everything. It uses Go's templating system under the hood, which gives you conditional logic based on variables like .chezmoi.os, .chezmoi.arch, and .chezmoi.hostname.
To get started on a fresh machine, it's literally one command:
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply YOUR_GITHUB_USERNAMEThat pulls down the repo, runs any setup scripts, and applies all your dotfiles. On the Mac, it automatically installs Homebrew if it's not there, pulls down all my packages, and lays down the right configs. On my Linux box, it runs a system update and installs everything through pacman. Same repo, same command, completely different results based on the platform.
The magic is in three pieces: platform-specific scripts, the ignore file, and templated configs.
Platform-specific scripts
Chezmoi lets you suffix scripts with _darwin or _linux to target specific platforms. So I have:
run_once_before_01-install-packages_darwin.sh– installs everything via Homebrewrun_once_before_01-install-packages_linux.sh– installs everything via pacmanrun_once_before_00-system-update_linux.sh– runs a system update on Linux first
The run_once_before prefix means these run exactly once, before the dotfiles are applied, and only on the matching platform. Chezmoi tracks which scripts it has already run, so re-running chezmoi apply doesn't re-trigger them. The macOS script handles Homebrew installation first (including Apple Silicon vs Intel path detection), then installs core tools, development packages, fonts, shell plugins, and GUI apps all in one go:
# Core utilities
brew install chezmoi duf fastfetch glances pv smartmontools
# Development tools
brew install git gh rustup nano go-task mkcert
# Shell & terminal
brew install fzf nvm powerlevel10k \
zsh-syntax-highlighting zsh-autosuggestions \
zsh-history-substring-search
# Applications
brew install --cask firefox warp logseq zen zed \
meld aerospace aws-vault docker
# Fonts
brew install --cask font-jetbrains-mono \
font-jetbrains-mono-nerd-font \
font-meslo-lg-nerd-fontSince brew install is idempotent, running this on a machine that already has everything installed is a no-op. Pretty clean.
The ignore file
The .chezmoiignore file is where the platform separation happens. It's automatically interpreted as a template (even without a .tmpl extension), and it tells chezmoi which files to skip on each platform. One thing that tripped me up early on – the {{- }} dashes that trim whitespace really matter here. Without them you can end up with blank lines in the ignore file that chezmoi treats as patterns, and a blank line can match everything. I lost about an hour to that one before I figured out why all my files were being ignored. Also, the patterns use target paths (the actual file paths in your home directory), not the chezmoi source-state paths with prefixes like dot_:
{{- if eq .chezmoi.os "darwin" }}
# Skip Linux-only configs on macOS
.config/hypr/
.config/waybar/
.config/wlogout/
.config/gtk-3.0/
.config/systemd/
*_linux.sh
{{- else if eq .chezmoi.os "linux" }}
# Skip macOS-only configs on Linux
.config/aerospace/
.config/sketchybar/
*_darwin.sh
{{- end }}This is what keeps things sane. My entire Hyprland setup – the compositor config, Waybar status bar, wlogout, GTK themes, systemd services – none of it gets applied on the Mac. And the AeroSpace and SketchyBar configs don't show up on Linux. Each machine only gets what it needs.
Templated configs
On Linux I use the system-wide oh-my-zsh installation for shell plugins. On macOS I source the Homebrew-installed plugins directly – no oh-my-zsh at all. They're functionally the same plugins (powerlevel10k, zsh-syntax-highlighting, zsh-autosuggestions, zsh-history-substring-search), but the paths are completely different: Homebrew on Apple Silicon puts them in /opt/homebrew/share/ while Arch puts them in /usr/share/zsh/plugins/. You can't just copy-paste your plugin loading code between platforms. That's the main reason my .zshrc is a chezmoi template – stored as dot_zshrc.tmpl and processed through Go's text/template engine before being written to disk:
{{- if eq .chezmoi.os "darwin" }}
# Initialize Homebrew
eval "$(/opt/homebrew/bin/brew shellenv)"
{{- end }}
# ... shared config in the middle ...
{{- if eq .chezmoi.os "darwin" }}
alias update="brew update && brew upgrade"
alias cleanup="brew cleanup"
{{- else if eq .chezmoi.os "linux" }}
alias update="sudo pacman -Syu"
alias cleanup="sudo pacman -Rsn $(pacman -Qtdq)"
{{- end }}The template handles all the branching transparently. Same aliases, same prompt, same muscle memory – just different plumbing underneath. I sit down at either machine and it's the same shell experience.
For the day-to-day workflow of actually updating configs, chezmoi gives you two approaches. The "chezmoi-native" way is chezmoi edit ~/.config/aerospace/aerospace.toml which opens the source file in your editor (so templates keep their Go conditionals), then chezmoi diff to preview, and chezmoi apply to write. Or if you prefer tweaking the live file first: edit it directly, then chezmoi add to pull changes back into the source repo. Either way, once you commit and push, chezmoi update on the other machine pulls and applies in one step.
chezmoi edit ~/.config/aerospace/aerospace.toml # opens the source file in $EDITOR
chezmoi diff # preview what will change
chezmoi apply # write to home directoryA lot of configs end up being genuinely cross-platform with no templating needed at all. My Zed editor settings, Warp terminal config, and Powerlevel10k theme file work identically on both machines – I edit them once and both machines pick up the change on next sync. It's only the platform-specific stuff like shell plugin paths, package manager aliases, and Homebrew initialization that need the template treatment. The ratio of shared to platform-specific config ends up being pretty heavily tilted toward shared, which is the whole point.
Where to go from here
If you're splitting time between Linux and macOS – whether it's a personal and work machine like me, or any other combination – this setup has been working well. The tiling behavior, the keybindings, the shell environment – it all carries over in a way that means I don't have to context-switch when I switch machines. I just sit down and start working.
You could expand on this in a bunch of directions – adding SketchyBar as a macOS status bar to replace the default menu bar (I've got configs for that in the repo already, with AeroSpace workspace integration), using JankyBorders to add colored window borders so you can see which window has focus, setting up on-window-detected rules in AeroSpace to auto-assign apps to specific workspaces, or templating even more of your config files for cross-platform use. The chezmoi repo is the foundation, and you can build on it to your hearts content from here.
Thanks for reading, and happy coding!