Hold a button, move your trackball, scroll by pixels — not by notches.
Built for Arch + Wayland + Hyprland. It does not reimplement scrolling: it hands the job to libinput, which is the only component on the system that can emit true continuous-source scroll events.
libinput already has exactly the feature you want — scroll_method = on_button_down. Button scrolling emits axis events with source
LIBINPUT_POINTER_AXIS_SOURCE_CONTINUOUS, where (per libinput.h) "a scroll
value of 1 represents the equivalent relative motion of 1" and the sequence is
terminated with a 0 event that GTK/Firefox use to trigger kinetic scrolling.
That is pixel-precise, macOS-style scrolling, done inside the compositor's own
input path.
If your scroll button is on the same device as your pointer, you do not need this program. Configure that device directly and stop reading.
On Omarchy Quattro (Lua config, ~/.config/hypr/input.lua):
hl.device({
name = "ploopy-corporation-ploopy-adept-trackball-mouse",
scroll_method = "on_button_down",
scroll_button = 277,
})On stock Hyprland (legacy .conf config):
device {
name = ploopy-corporation-ploopy-adept-trackball-mouse
scroll_method = on_button_down
scroll_button = 277
}
libinput's one hard constraint is that the button and the motion must come from the same device. A QMK keyboard sending BTN_SIDE while a separate trackball supplies the motion can never satisfy that.
So smoothscroll grabs both devices exclusively and re-emits everything through
a single merged virtual pointer. Hyprland then applies on_button_down to that
device, and the constraint is satisfied. The daemon performs no scroll
arithmetic whatsoever.
Coasting comes from the same trick rather than from synthesized wheel events.
On release, the virtual scroll button is held down while decaying REL_X/
REL_Y motion is injected at 125Hz; the release is sent once the velocity
falls below min_velocity. libinput turns that into a real decaying continuous
scroll with a correct terminating event — indistinguishable from a physical
glide, and clients get proper kinetic behaviour for free.
Re-pressing the button mid-coast catches the fling instead of restarting it, as on macOS.
cargo build --release
sudo install -Dm755 target/release/smoothscroll /usr/local/bin/smoothscroll
sudo install -Dm644 smoothscroll.toml /etc/smoothscroll.toml
sudo install -Dm644 smoothscroll.service /etc/systemd/system/smoothscroll.servicesudo smoothscroll --listCopy the names into devices in /etc/smoothscroll.toml. Substring matching is
case-insensitive, so "Aurora Sweep rev1 Mouse" matches
splitkb.com Aurora Sweep rev1 Mouse. List only pointer nodes — a device named
... Mouse, never the bare keyboard node.
This is the step that actually makes scrolling happen. Without it the daemon runs, merges devices correctly, and does nothing visible.
Omarchy Quattro uses Lua configs. hyprland.lua does require("hypr.input"),
so ~/.config/hypr/input.lua is read and any leftover ~/.config/hypr/input.conf
is ignored entirely. Add to ~/.config/hypr/input.lua:
hl.device({
name = "smoothscroll-virtual-pointer",
scroll_method = "on_button_down",
scroll_button = 277,
scroll_button_lock = false,
sensitivity = -0.5,
natural_scroll = true,
})On stock Hyprland with legacy .conf configs, the same thing in input.conf:
device {
name = smoothscroll-virtual-pointer
scroll_method = on_button_down
scroll_button = 277
}
Because the daemon grabs your physical devices exclusively, Hyprland only ever
sees the virtual pointer — so sensitivity, natural_scroll and friends must
live in this block. Any tuning still attached to the physical device is
inert while the daemon runs.
sudo smoothscroll --debug --config /etc/smoothscroll.toml
hyprctl reload # in another terminal, AFTER the daemon startsThe reload matters: the virtual device is created at startup and destroyed on
exit, so the device{} config has to be applied to the current instance.
The test that it works: the cursor freezes while you hold the scroll button.
That is libinput consuming motion for scrolling. If the cursor keeps moving, the
device block is not being applied — check hyprctl configerrors and confirm
hyprctl devices lists smoothscroll-virtual-pointer.
sudo systemctl enable --now smoothscroll
sudo systemctl status smoothscrollenable --now starts it immediately and on every boot. Logs, including the
device-capture lines:
journalctl -u smoothscroll -fRetuning friction or min_velocity later means editing /etc/smoothscroll.toml
(not the copy in this repo) and restarting:
sudo systemctl restart smoothscrollThe daemon holds an exclusive grab on your trackball. If it ever misbehaves,
killing it releases every grab immediately — the kernel drops EVIOCGRAB when
the process's file descriptors close — and your devices go straight back to
normal. There is no state to clean up.
sudo systemctl stop smoothscroll # under systemd — use this one
sudo pkill -x smoothscroll # foreground run, or no systemdUnder systemd, use systemctl stop. The unit sets Restart=always, so
pkill only makes systemd start it again two seconds later. The nuclear
version, which stops it and prevents the restart:
sudo systemctl kill -s SIGKILL smoothscroll; sudo systemctl stop smoothscrollWorth keeping somewhere reachable from a TTY. All of these are safe to run when the daemon is not running.
To stop it starting at boot without uninstalling:
sudo systemctl disable --now smoothscroll# 1. Stop and forget the service
sudo systemctl disable --now smoothscroll
sudo rm -f /etc/systemd/system/smoothscroll.service
sudo systemctl daemon-reload
# 2. Remove the binary and config
sudo rm -f /usr/local/bin/smoothscroll /etc/smoothscroll.tomlThen delete the hl.device({ name = "smoothscroll-virtual-pointer", ... }) block
from ~/.config/hypr/input.lua and reload:
hyprctl reloadLeaving the block in place is harmless — Hyprland ignores config for a device
that does not exist — but any pointer tuning you moved into it goes away with
the daemon, so move sensitivity and natural_scroll back onto your physical
device block at the same time.
Nothing else is touched: no udev rules, no kernel modules, no files outside the three paths above.
The trigger key must send mouse button 6, not 4 or 5:
// keymap.c — newer QMK keycode alias
MS_BTN6
// older QMK (pre-2022 naming)
KC_MS_BTN6Button 6 arrives as evdev BTN_FORWARD (277), which is what
scroll_button defaults to. Avoid buttons 4 and 5: they land on BTN_SIDE
(275) and BTN_EXTRA (276), which browsers bind to back/forward — a guaranteed
conflict. Note that evdev's BTN_FORWARD/BTN_BACK names are misleading; no
common application binds them.
Confirm your firmware actually exposes 8 buttons:
awk '/your device name/,/^$/' /proc/bus/input/devicesA KEY=ff0000 0 0 0 0 line means bits 272–279 are set — all eight mouse
buttons. If you see 1f0000 instead, the descriptor stops at button 5 and you
need a newer QMK.
- Root is required.
EVIOCGRABon/dev/input/event*and writing to/dev/uinputboth need it. The alternative — joining theinputgroup — would let every process in your session read your keystrokes, which is worse. The bundled unit drops all capabilities and confines the daemon to those two device classes. - A quick tap passes through as a click. libinput forwards the button if it is pressed and released without motion. That is libinput's behaviour, not a bug here; it is also why the button stays usable.
- Terminals still scroll by lines. foot, alacritty and ghostty quantize to text rows no matter what they are fed. Smoothness shows up in GTK, Qt, Electron and Firefox.
- Grabbed devices are exclusive. List only pointer nodes. Grabbing a plain keyboard node would swallow your typing.
- Hotplug is handled.
/dev/inputis watched with inotify; unplugging the trackball drops it and replugging re-captures it. - Signals are handled so the virtual button is always released on exit — a
daemon killed mid-scroll must not leave a button stuck down.
SIGKILLcannot be caught, but the kernel still drops the grab when the process dies, so the worst case is a momentarily stuck button, not a stuck trackball. - Omarchy Quattro reads Lua, not
.conf.hyprland.luarequireshypr.input, so~/.config/hypr/input.luais authoritative and anyinput.confstill sitting in that directory is dead weight.
| Symptom | Change |
|---|---|
| Coast too short | lower friction toward 0.0005, or raise it toward 0.02 for longer |
| Slow tail feels draggy | raise min_velocity |
| Flicks overshoot | lower max_velocity |
| No coast at all | you stopped moving before releasing; flick and release while still rolling |
| Scroll direction wrong | natural_scroll in the hl.device block |
| Scroll speed | scroll_factor in the hl.device block |
| Cursor speed | sensitivity in the hl.device block |
Everything in the first column of smoothscroll.toml needs a
sudo systemctl restart smoothscroll; everything in the hl.device block needs
a hyprctl reload.
Nothing scrolls, cursor still moves while the button is held. libinput never
entered button-scroll mode, so the device block is not reaching Hyprland. On
Omarchy Quattro this is almost always because the config went into
~/.config/hypr/input.conf, which Quattro does not read — it must be
input.lua. Check hyprctl configerrors, then confirm the device is present:
hyprctl devices | grep -A4 smoothscrollNothing scrolls, and the button still does its normal thing (browser forward, etc). Same cause: nothing has claimed the button, so libinput passes it straight through.
--monitor shows no button at all. The firmware is not sending a mouse
button on that key. Nothing downstream can help:
sudo smoothscroll --monitorIt is passive — no grab — so it is safe to run alongside the daemon or while working normally. It prints every button press with its evdev name and the QMK keycode that produces it.
Everything looks right in --debug but apps do not scroll. Check you are not
testing in a terminal; foot, alacritty and ghostty quantize to text rows.
Replaces LinuxScrollFix, which
emitted REL_WHEEL and was therefore structurally limited to whole wheel
detents. Inspired by Mac Mouse Fix.