NixOS from scratch · Part 2
Live theme switching on NixOS across 10 apps at once
I built a theme system in Nix that generates configs for waybar, dunst, kitty, neovim, tmux, Firefox, VSCode, Qt5 apps, swaylock, and the login greeter from a single color palette.
I run NixOS on all my machines and I kept changing my mind about color themes. Every time I switched meant editing waybar CSS, dunst config, tmux conf, kitty colors, neovim colorscheme, Firefox userChrome, VSCode settings, Qt5 palette, swaylock config. After doing this manually three times I decided to automate it.
The result: a rofi picker that shows a 3x3 grid of theme previews. Pick one, everything switches. 9 themes, 12+ applications, under a second.
How the data flows
Each theme is a meta.nix file that exports a color palette and some metadata:
# themes/catppuccin/meta.nix
{
name = "Catppuccin Mocha";
wallpaper = ./wallpaper.png;
previewPng = ./preview.png;
neovimColorscheme = "catppuccin-mocha";
gtkTheme = "catppuccin-mocha-mauve-standard+default";
vscodeThemeName = "Catppuccin Mocha";
vscodeExtension = pkgs.vscode-extensions.catppuccin.catppuccin-vsc;
rofiColors = { bg = "#1e1e2e"; bg2 = "#313244"; fg = "#cdd6f4";
selected = "#cba6f7"; urgent = "#f38ba8"; };
dunstColors = { bg2 = "#313244"; fg = "#cdd6f4";
accent = "#cba6f7"; red = "#f38ba8"; };
tmuxColors = { bg = "#1e1e2e"; fg = "#cdd6f4"; accent = "#cba6f7";
bg2 = "#313244"; fgOnAccent = "#1e1e2e"; };
swaylockColors = { bg = "#1e1e2e"; bg2 = "#313244"; fg = "#cdd6f4";
accent = "#cba6f7"; clear = "#89dceb";
wrong = "#f38ba8"; green = "#a6e3a1";
purple = "#cba6f7"; yellow = "#f9e2af";
orange = "#fab387"; };
}
A withAssets function takes each theme meta and generates every config file from the palette:
withAssets = t: t // {
rofiTheme = mkRofiTheme t.rofiColors;
dunstConf = mkDunstConf t.dunstColors;
tmuxConf = mkTmuxConf t.tmuxColors;
firefoxCss = mkUserChromeCss t.tmuxColors;
qt5ctColors = mkQt5ctColors t.tmuxColors;
qt5ctQss = mkQt5ctQss t.tmuxColors;
swaylockConf = mkSwaylockConf t.swaylockColors;
regreetCss = mkRegreetCss t.tmuxColors;
vscodeSettings = mkVscodeSettings t.vscodeThemeName;
};
Each mk* function is a pkgs.writeText that generates a complete config file. The files are built at Nix evaluation time and stored in /nix/store/. The theme switcher just symlinks them into the right locations.
What each generator produces
mkTmuxConf generates a status line with powerline-style separators using the theme’s accent color:
set -g status-style "bg=${bg},fg=${fg}"
set -g status-left "#[bg=${bg},fg=${accent}]#[bg=${accent},fg=${fgOnAccent},bold] #S #[bg=${bg},fg=${accent}] "
set -g window-status-separator ""
mkDunstConf produces a full dunstrc with transparency on the background (${bg2}ee) and accent-colored frames for normal urgency, red for critical with infinite timeout.
mkUserChromeCss generates a Firefox/Zen userChrome.css with 20+ CSS custom properties covering every UI surface:
:root, :root:-moz-lwtheme {
--toolbar-bgcolor: ${bg} !important;
--urlbar-box-bgcolor: ${bg2} !important;
--tab-selected-bgcolor: ${bg2} !important;
--tab-loading-fill: ${accent} !important;
--zen-primary-color: ${accent} !important;
--zen-colors-secondary: ${bg2} !important;
}
The Zen-specific properties are included because I use Zen browser. Standard Firefox ignores them.
mkSwaylockConf strips the # prefix from hex colors because swaylock wants raw hex without it. A helper function s = c: builtins.substring 1 6 c handles the conversion. The config sets 10 different color parameters for the lock screen indicator ring, including separate colors for the “clear” animation, “wrong password” flash, key highlight, backspace highlight, and caps lock states.
mkQt5ctColors generates a QPalette with 21 color roles per state (active, inactive, disabled). The roles map to things like WinText, Button, Light, MidLt, Dark, Mid, Text, BtnText, Base, Window, Shadow, Highlight, HighlightedText, Link, and more. Each color gets an alpha prefix: #ff for opaque, #80 for semi-transparent, #40 for dim. This is the format qt5ct expects for custom palettes.
mkQt5ctQss generates a 100+ rule QSS stylesheet covering QMainWindow, QMenuBar, QMenu, QToolBar, QTreeView, QTableView, QLineEdit, QComboBox, QPushButton, QTabBar, QProgressBar, QScrollBar, QCheckBox, and more. This is what themes KeePassXC and other Qt apps that respect qt5ct.
mkVscodeSettings merges the theme name into a base settings.json that I maintain separately (formatters, font settings, extension configs). The merged file gets symlinked on theme switch.
The rofi picker
The picker is a shell script generated by pkgs.writeShellApplication. It uses rofi’s script mode (-modi "pick:theme-switch --pick") where the script is called multiple times: once to generate the list, and again when the user selects an item.
Theme entries use rofi’s icon protocol:
printf '%s\x00icon\x1f%s\n' "Catppuccin Mocha" "/nix/store/...-preview.png"
The \x00icon\x1f is rofi’s way of attaching an icon to a list item. The preview images are 400x370 screenshots of each theme.
There’s a preview mode: selecting a theme applies it immediately but doesn’t save. Pressing Escape reverts to the original theme. The script saves the current theme to ~/.cache/theme-switch-saved before previewing, and restores it on cancel. Pressing Enter on the “Keep” option writes a confirmation file that the script reads on the next invocation.
Live reload per application
The applyCase function in the registry generates a case statement per theme. When a theme is selected, it:
swww: swww img "${wallpaper}" for the wallpaper. Instant, animated transition.
Symlinks: waybar CSS, rofi theme, kitty colors, starship prompt config all get ln -sf to the generated files.
dunst: dunstctl reload or falls back to restarting the systemd unit.
waybar: pkill -SIGUSR2 waybar. Waybar watches for this signal and reloads its CSS.
tmux: This one’s interesting because tmux sockets can be in different paths depending on how the session was started. The script scans both /tmp/tmux-${UID}/ and /run/user/${UID}/ for sockets:
while IFS= read -r _tsock; do
tmux -S "$_tsock" source-file "${tmuxConf}" 2>/dev/null || true
done < <(find /tmp /run/user/"$_uid" -maxdepth 3 \
-path "*/tmux-$_uid/*" -type s 2>/dev/null)
Every running tmux session gets the new config applied. I was surprised this worked across different socket types without issues.
kitty: Iterates over /tmp/kitty-* sockets and sends kitty @ --to "unix:$sock" set-colors -a -c "${kittyColors}". Every open kitty window updates instantly.
neovim: Scans for RPC sockets at /run/user/${UID}/nvim.* and sends :colorscheme ${neovimColorscheme}<CR> via nvim --server "$sock" --remote-send. Every running neovim instance switches colorscheme.
GTK: dconf write /org/gnome/desktop/interface/gtk-theme and icon-theme. Applications that respect GTK settings pick it up. Some need a restart.
VSCode: Symlinks settings.json with the new workbench.colorTheme value. VSCode detects the file change and reloads. All 9 theme extensions are pre-installed via programs.vscode.profiles.default.extensions so the theme is always available.
Firefox/Zen: Symlinks userChrome.css into every profile directory found by scanning for cert9.db. Requires browser restart for changes to take effect (Firefox doesn’t support live CSS reload).
Qt5 apps: Symlinks the palette and QSS files into ~/.config/qt5ct/. Most Qt apps pick it up on the next window redraw.
regreet: The login greeter gets a CSS and wallpaper symlink into /var/lib/regreet-theme/. Next login shows the new theme.
If we’re in preview mode (THEME_PICKER_PREVIEW=1), the current theme name and nvim colorscheme cache are not updated. Only on final confirmation does it write to ~/.cache/current-theme and ~/.cache/nvim-colorscheme, and send a notification via notify-send.
Home Manager integration
Two activation hooks handle the Nix side:
clearThemeMigration runs before Home Manager’s checkLinkTargets to remove files that were previously owned by HM but are now managed by the theme switcher (rofi config, VSCode settings, swaylock config). Without this, HM complains about existing files it can’t overwrite.
initTheme runs after writeBoundary and sets up initial symlinks pointing to the last applied theme (read from ~/.cache/current-theme, defaulting to Nord). It only creates symlinks that don’t already exist ([ -L "$file" ] || ln -sf ...), so it doesn’t overwrite manual theme switches on every HM activation.
There’s also a sed step that expands __HOME__ placeholders in the qt5ct config, because qt5ct needs absolute paths but Home Manager doesn’t always know the home directory at evaluation time.
Adding a new theme
- Create a directory with
meta.nix, a wallpaper, and a preview screenshot - Fill in the color palette and metadata
- Add it to the
allThemeslist in registry.nix - Install the GTK theme package, neovim plugin, and VSCode extension in the home config
Everything else is generated. The waybar CSS is the only file per theme that isn’t generated from the palette because waybar styling is complex enough that a template wouldn’t produce good results. Each theme has a hand-tuned waybar stylesheet.
Adding Kanagawa took about 15 minutes after the system was in place. Most of that was picking the right hex values for the QPalette mapping.