From Sway to Hyprland, with all the sharp edges
I'd been on Sway for years. It's solid, i3-compatible, boring in the good way. But Hyprland kept showing up in screenshots with features I wanted (proper mouse-drag move/resize on floating windows, smoother animations, the hy3 layout plugin) and I finally caved. This post is the log of that port, including the bits that broke.
Setup: Fedora 43 on a Framework laptop, AMD GPU, Swedish keyboard.
The plan
Sway's config is declarative and fairly small. The logic that matters
was in a Clojure/babashka script called befriasway.bb that tracks how
much time I spend on workspace 10 (my "slacker" workspace - where the
browser with distraction-of-the-day lives), and makes it hard to enter
with a randomized nag prompt. Losing that would be sad.
So the plan:
Port `~/.config/sway/config` → `~/.config/hypr/hyprland.conf`
Generalize `befriasway.bb` to dispatch to either sway or hyprland
Port the waybar config (same daemon, different compositor modules)
Keep swaylock → actually swap to hyprlock, because of a fingerprint
thing I'll get to
Install hy3 so Hyprland feels more like Sway
Keep Sway installed as a fallback. If Hyprland explodes I just log out and pick Sway from GDM.
Porting the config, mostly mechanical
Hyprland config is not i3-compatible, but the concepts map 1:1:
(See original source org file for the full sway→hyprland mapping table.)
The thing that needed thought was sticky (show on all workspaces).
Hyprland has no true sticky. Closest match is pin, which pins a
floating window to the monitor so it follows you across workspaces.
Good enough for my emacs-super-x transient frame.
Fedora's sway package includes a bunch of helpful snippets in
/usr/share/sway/config.d/ that handle volume/brightness/media keys.
Hyprland doesn't, so I had to add them explicitly:
1bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
2bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
3bindl = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
4bindel = , XF86MonBrightnessUp, exec, brightnessctl set 5%+
5bindel = , XF86MonBrightnessDown, exec, brightnessctl set 5%-
bindel repeats when held, bindl works when the screen is locked.
befriawm.bb: one script for both WMs
The old script used swaymsg everywhere. Rather than duplicate it I
added a tiny abstraction layer that auto-detects Hyprland by looking
for $HYPRLAND_INSTANCE_SIGNATURE:
1(def hypr? (some? (System/getenv "HYPRLAND_INSTANCE_SIGNATURE")))
2
3(defn wm-switch [ws]
4 (if hypr?
5 (sh "hyprctl" "dispatch" "workspace" ws)
6 (sh "swaymsg" "workspace" ws)))
7
8(defn wm-active-workspace []
9 (if hypr?
10 (str (:id (json/parse-string (:out (sh "hyprctl" "-j" "activeworkspace")) true)))
11 (let [wss (json/parse-string (:out (sh "swaymsg" "-t" "get_workspaces")) true)]
12 (some #(when (:focused %) (:name %)) wss))))
Hyprland's activeworkspace command returns one object directly,
which is nicer than sway's "find :focused in the list of all
workspaces" dance. The rest of the logic (timestamp log, daily slack
duration, the mystery-button nag) is unchanged.
Installing hy3
Hyprland's built-in layouts are dwindle (auto-split) and master
(like dwm). Neither feels like Sway. hy3 is a plugin that gives you
i3/sway-style manual split containers and proper tabbed groups. Its
tabbed implementation is actually better than Hyprland's built-in
groups.
On Fedora you don't package-install hy3. You use Hyprland's own
plugin manager hyprpm, because hy3 needs to be compiled against
your exact Hyprland commit:
1hyprpm update
2hyprpm add https://github.com/outfoxxed/hy3
3hyprpm enable hy3
This has to run inside a live Hyprland session — hyprpm needs
the running socket. So the first login of Hyprland is without hy3;
you run the install script, then reload.
In hyprland.conf:
1exec-once = hyprpm reload -n # load plugins at startup
2general {
3 layout = hy3
4}
5plugin {
6 hy3 {
7 tabs { height = 22 ... }
8 }
9}
10bind = $mod, H, hy3:movefocus, l # replaces movefocus
11bind = $mod SHIFT, H, hy3:movewindow, l
12bind = $mod, B, hy3:makegroup, h # real splits (i3's splith)
13bind = $mod, V, hy3:makegroup, v
14bind = $mod, W, hy3:changegroup, tab
After this, the layout feels like sway. Windows go where you tell them, splits stay where you put them.
One visual gotcha: hy3's default tab bar is semi-transparent with white text on top of whatever window is behind it. Unreadable. Fix with explicit colors:
1plugin {
2 hy3 {
3 tabs {
4 height = 22
5 col.active = rgba(0099ccff)
6 col.active.border = rgba(0099ccff)
7 col.active.text = rgba(000000ff)
8 col.focused = rgba(333333ff)
9 col.focused.text = rgba(ffffffff)
10 col.inactive = rgba(222222ff)
11 col.inactive.text = rgba(ccccccff)
12 }
13 }
14}
The key is the ff alpha — fully opaque. Then pick whatever hues
you like; mine are cyan/grey.
Waybar
Waybar itself works fine on Hyprland. Just swap the sway/* modules
for hyprland/*:
(See original source org file for the full sway→hyprland mapping table.)
CSS gotcha: sway uses .focused to mark the active workspace button,
Hyprland uses .active. My old style.css had only the former and
the current workspace wasn't highlighted. Fix is a one-liner:
1#workspaces button.focused,
2#workspaces button.active {
3 background-color: rgba(0,43,51,0.85);
4}
This works on both compositors now.
Workspace animation
Sway switches workspaces instantly. Hyprland's default animation slides the whole screen, which I found distracting. Kill it:
1animations {
2 enabled = true
3 animation = workspaces, 0
4}
Keeps window open/close fades (which I like), instant workspace switch (which I need).
xdg-desktop-portal — OBS can't record, redux
This one ate an hour.
OBS's "Screen Capture (PipeWire)" showed in red in the source list. Checked logs:
1[pipewire] No capture sources available
2Failed to register with host portal: Could not register app ID:
3 Connection already associated with an application ID
4Source ID 'pipewire-screen-capture-source' not found
Three portal implementations were installed:
xdg-desktop-portal-gtk, xdg-desktop-portal-wlr, and
xdg-desktop-portal-hyprland. Only the GTK one was actually running.
The hyprland portal (the one that actually implements
org.freedesktop.portal.ScreenCast for Hyprland) was dead.
I'd added these to exec-once:
1exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE
2exec-once = systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE
3exec-once = systemctl --user start xdg-desktop-portal-hyprland.service
But exec-once runs things in parallel, so sometimes the portal
service started before the env vars got imported into the user bus.
The fix is to chain them with ; and kill any stale state first:
1exec-once = systemctl --user stop xdg-desktop-portal xdg-desktop-portal-hyprland xdg-desktop-portal-gtk ; sleep 1 ; systemctl --user start xdg-desktop-portal-hyprland ; sleep 1 ; systemctl --user start xdg-desktop-portal
Not pretty. Works. OBS now records windows.
The xdg-desktop-portal design is, to put it politely, too subtle. Three competing backends, a selector that picks the wrong one half the time, env propagation that has to happen through systemd's user bus before anything works, and no good diagnostic for "portal can't find compositor". Every Wayland setup hits this eventually.
Fingerprint on the lock screen
I have a fingerprint sensor enrolled for pam_fprintd. GDM offers
fingerprint login fine. Swaylock does not — because swaylock has no
fingerprint support, period. The PAM chain is correct (pam_fprintd
is wired as sufficient in system-auth), swaylock just doesn't
implement the PAM conversation that lets you use it.
Hyprlock does. Once you enable it:
1# ~/.config/hypr/hyprlock.conf
2auth {
3 fingerprint {
4 enabled = true
5 ready_message = Scan fingerprint to unlock
6 }
7}
...touching the sensor unlocks the screen. Password still works as fallback.
So the migration from swaylock to hyprlock was worth it on its own merits.
Idle handling: swayidle → hypridle
swayidle still works under Hyprland technically, but it calls
swaymsg for display power off, which won't work. Portable idle
logic is also cleaner with hypridle:
1general {
2 lock_cmd = pidof hyprlock || hyprlock
3 before_sleep_cmd = /home/joakim/bin/befriawm.bb to 2 && loginctl lock-session
4 after_sleep_cmd = hyprctl dispatch dpms on
5}
6listener {
7 timeout = 300
8 on-timeout = /home/joakim/bin/befriawm.bb awayfrom10 && loginctl lock-session
9}
10listener {
11 timeout = 360
12 on-timeout = hyprctl dispatch dpms off
13 on-resume = hyprctl dispatch dpms on
14}
5 min idle: kick me off slacker workspace if I'm there, then lock via hyprlock. 6 min idle: screens off. Before sleep: jump to workspace 2 (safe default) and lock. Same behavior as the sway version, less code.
Little stuff
Move workspace to other monitor: in sway I had bindsym $mod+p move workspace to output right. Hyprland's equivalent movecurrentworkspacetomonitor, r
doesn't wrap — on the rightmost monitor it's a no-op, so you can't
send it back. Use +1 instead, which cycles:
1bind = $mod, P, movecurrentworkspacetomonitor, +1
Tray applets don't autostart — Fedora's sway session included
nm-applet and blueman-applet via XDG autostart. Hyprland's session
doesn't hook that up, so wifi/bluetooth icons were missing. Just add:
1exec-once = nm-applet --indicator
2exec-once = blueman-applet
Firefox auto-routes to workspaces: I use the Window Titler extension
to prefix each Firefox window's title with [WS-N] where N is the
target workspace. Window Titler persists the prefix across restarts.
Then:
1windowrulev2 = workspace 2 silent, class:^(org\.mozilla\.firefox)$, title:^(\[WS-2\] .*)
2windowrulev2 = workspace 3 silent, class:^(org\.mozilla\.firefox)$, title:^(\[WS-3\] .*)
3...
(Note: Firefox's window class on Wayland is org.mozilla.firefox, not
firefox. Cost me one iteration.)
Wallpaper rotation: Hyprland's swaybg works but doesn't rotate.
swww does:
1#!/bin/sh
2DIR="/home/joakim/roles/Creative/art/backgrounds"
3pgrep -x swww-daemon >/dev/null || swww-daemon &
4sleep 1
5while :; do
6 img=$(find "$DIR" -maxdepth 1 -type f \
7 \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \
8(See original source org file for the full sway→hyprland mapping table.)
9
10 if [ -n "$img" ]; then
11 swww img "$img" --transition-type any --transition-fps 60
12 ln -sf "$img" /tmp/current-wallpaper
13 fi
14 sleep 600
15done
The symlink /tmp/current-wallpaper trick is so hyprlock can show the
same image on the lock screen by pointing at the symlink. Otherwise
hyprlock would freeze on a stale screenshot.
Wacom pen mapping
I have a Wacom Movink 13 pen display. In sway I mapped the pen to the tablet screen by its full description:
1input "1386:1008:Wacom_Movink_13_Pen" {
2 map_to_output "Wacom Tech Wacom DTH135 4IH01H1000586"
3}
In Hyprland, device.output only accepts the monitor name (like
DP-3) or a desc:-prefixed description. The plain description
silently no-ops, which is why my pen was mapping to the wrong screen.
1device {
2 name = wacom-movink-13-pen
3 output = desc:Wacom Tech Wacom DTH135 4IH01H1000586
4}
5device {
6 name = wacom-movink-13-finger
7 output = desc:Wacom Tech Wacom DTH135 4IH01H1000586
8}
The device names in Hyprland are lowercased and hyphen-joined ("Wacom
Movink 13 Pen" → "wacom-movink-13-pen"). You can see the exact names
with hyprctl devices.
Things I don't have in Hyprland
**Title bars on floating windows.** Hyprland has never drawn server-side
title bars, only borders. The `hyprbars` plugin adds fake ones but
fights with GTK CSD apps. I'll probably live without.
**True sticky windows.** `pin` is close but only works for floating
windows, and only follows the monitor, not across monitors.
**Fedora's sway bindings includes.** I have to maintain volume/brightness
binds myself.
Was it worth it
Yes, but the xdg-desktop-portal stuff made me want to flip a table.
Everything else was mechanical translation. The Hyprland-specific
nice things (smoother animations, hy3's better tabs, hyprlock's
fingerprint support, pin as a reasonable sticky-window analog) add
up to a clear upgrade. Sway is still installed as my "the WM is
broken, get me to a working shell" escape hatch, which I did need
exactly once during the port.
If you're considering the same move: keep sway around. Port in a weekend. Budget an hour for xdg-desktop-portal.