Making the ZSA Voyager Trackpad Usable on macOS
The ZSA Voyager Trackpad is very close to being the keyboard I wanted: split Voyager layout, a built-in pointing device, and enough firmware control to keep my hands on the board. Out of the box, though, the trackpad was not quite usable for me on macOS.
Pointer movement worked. The rough edges were the interactions around it.
- I wanted my mouse layer to appear automatically while using the trackpad.
- I wanted two-finger drag to scroll.
- I wanted two-finger tap to right-click.
- I wanted the pointer not to jump after a scroll gesture.
- I wanted this in firmware, not through a macOS helper app.
The final setup keeps Oryx for the keyboard layout and uses local QMK changes for the behavior Oryx cannot express. The important trick is that macOS does not need to understand the Voyager as an Apple multitouch trackpad. The firmware can detect the gestures from the raw trackpad contacts and send normal HID mouse wheel and button reports, which macOS already handles well.
This is not a general macOS gesture implementation. It does not add pinch, three-finger swipes, Mission Control, or inertial scrolling. It just makes the Voyager Trackpad behave like a useful pointing device for normal work.
Result
The result feels like this:
- Touch the trackpad and the keyboard turns on a mouse layer.
- Keep moving on the trackpad and the mouse layer stays alive.
- Stop using the trackpad and the mouse layer turns off after a short timeout.
- Press a key that passes through the mouse layer and the mouse layer turns off immediately.
- Move one finger and the pointer moves normally.
- Drag two fingers and QMK sends mouse wheel events.
- Tap two fingers and QMK sends a secondary click.
- Switch from scrolling back to pointing and the cursor stays anchored.
My layout happens to use layer 4 for the trackpad/mouse overlay, but the approach does not depend on that. Pick whatever layer makes sense for your keymap.
What I Started With
I used ZSA's QMK fork, not upstream QMK:
Then I exported my layout source from Oryx and used that as the keymap under:
I still treat Oryx as the source of truth for layout shape. The local repository is where I keep behavior patches against ZSA's trackpad module and a small amount of keymap glue.
If you are following this, make a backup of your current Oryx firmware first. Being able to flash back to a known-good binary makes experimenting much less stressful.
Why macOS Needed Firmware Help
On my Mac, the Voyager shows up as a generic HID digitizer touchpad:
That is enough for basic pointer movement, but it did not give me reliable native macOS two-finger scrolling or right-click. The key detail was the input mode reported to the QMK module.
ZSA's navigator_trackpad module has code paths for PTP-style touchpad reports and for fallback mouse reports. The host can select an input mode through a HID feature report. On my Mac, digitizer_touchpad_get_input_mode() stayed at 0, which is the mouse/fallback mode in this module.
That meant any custom gesture handling hidden behind:
was the wrong place for macOS. The working fix was to inspect the raw contacts before the PTP-vs-fallback split. Once QMK sees two fingers, it consumes that gesture and sends ordinary mouse wheel or button reports.
The changed module lives here:
Automatic Mouse Layer
The auto layer has two parts:
- The trackpad module exposes a weak hook that fires when there is trackpad activity.
- The keymap implements that hook and turns on a layer with a timeout.
In navigator_trackpad_ptp.c, add a weak user hook near the top of the file:
Then call it anywhere the trackpad task decides there was real activity. At the end of navigator_trackpad_ptp_task(), the normal path looks like this:
There are a couple of early returns in the file too. If you add two-finger handling or scroll-mode handling, call the hook before returning from those paths as well:
Then implement the hook in your keymap. This is the minimal version:
That gives you the main behavior: touch the trackpad, get a mouse layer; stop touching it, lose the layer after one second.
The #define lines in these examples are C preprocessor macros, not comments. QMK uses them heavily for compile-time keymap and module settings.
If your keymap already has matrix_scan_user(), merge the timeout block into it instead of adding a second definition.
I also turn the layer off immediately when I press a key whose mouse-layer position is transparent. That lets the keys I actually use on the mouse layer stay active, while any pass-through typing key exits back to the base layout:
On my keymap, the layer-owned positions are the small cluster I actually use while pointing: D, F, T, R, and 5. Everything transparent on layer 4 is treated as an intent to type, so it exits the mouse layer. If your keymap already has process_record_user(), put this near the top of the existing function and keep your existing key handling below it.
Firmware Two-Finger Scroll
The scroll implementation is deliberately simple. When there are two fingers down, average their positions, compare that average to the last frame, accumulate fractional movement, and send mouse wheel ticks when the accumulated movement crosses a threshold.
First, I added a scroll divider in navigator_trackpad_ptp.h:
Lower values scroll faster. Higher values scroll slower. In real module code, these defaults live behind #ifndef guards so keymaps can override them, but the default value is the part worth showing here.
I also use a maximum per-frame delta and a small clamp helper in navigator_trackpad_ptp.c:
Then I added a small helper to get the average position of the active contacts:
The actual wheel sender uses normal QMK mouse reports:
If scrolling feels backwards, flip those signs. In my actual firmware, I wrapped the assignments with TRACKPAD_SCROLL_INVERT_X / TRACKPAD_SCROLL_INVERT_Y options, but the sign choice is the only important part.
Two-Finger Tap for Right-Click
Two-finger tap uses the same raw contact data. The firmware starts tracking when two fingers appear. If they disappear quickly without moving too much, it sends button 2.
The tap thresholds are separate from normal single-finger tap thresholds:
The actual module keeps these defaults behind override guards, but the values above are the part worth tuning. This made right-click more forgiving without making normal pointer/tap behavior sloppy.
The state is small:
The handler decides between tap and scroll:
send_mouse_buttons() is just a normal mouse report:
The Important Ordering Fix
This is the part that made the Mac behavior work.
After reading the sensor report and counting the active contacts, run the two-finger handler before deciding whether to send PTP reports or fallback mouse reports:
The PTP lift report is only for the case where the host actually is in PTP mode. It prevents the host from also seeing the two fingers as live touchpad contacts while firmware starts sending wheel events. On my Mac, the input mode stayed at 0, so the critical behavior was simply that the two-finger handler ran before this later split:
In other words: do not put the two-finger scroll implementation only inside the PTP branch if you want this to work on macOS.
There is one more important detail in that branch: reset_mouse_state() and prev_finger0_tip = false.
Without that reset, fallback mouse mode can keep stale one-finger coordinates while the firmware is consuming a two-finger scroll. If one finger remains on the pad as the gesture ends, the next normal pointer frame can compute a large relative delta from the old pre-scroll position. That feels like a cursor jump. Resetting the fallback state makes the next one-finger frame behave like a fresh touch and re-anchor at the current sensor position.
Avoiding Cursor Jumps
The same reset matters for the optional hold-to-scroll path. Any path that consumes trackpad movement as scroll should cancel fallback pointer tracking before returning:
That was the final tweak that made the trackpad feel consistent instead of merely functional. Two-finger scroll and hold-to-scroll now leave pointer movement in a clean state when control returns to fallback mouse mode.
Optional Hold-To-Scroll Key
I also added a hold-to-scroll key as a fallback. When held, trackpad movement sends wheel events instead of pointer movement. This is useful if a specific app or gesture is awkward with two-finger scroll.
The module keycodes are declared in:
Then navigator_trackpad.c handles the keycode:
The scroll-mode path uses the same get_scroll_position() and send_scroll_delta() helpers as two-finger scroll. Put TP_DSCR anywhere on your mouse layer if you want this behavior.
Oryx will not understand your local custom keycode unless you also teach its exported source about it. For my workflow, Oryx has a placeholder key and I replace it with TP_DSCR after exporting source locally.
Building and Flashing
I used Nix shells so I did not need a global QMK install:
That produces a firmware binary like:
For flashing, direct dfu-util was more reliable for me than qmk flash.
Put the Voyager into bootloader mode, then check that it appears as the ZSA DFU device:
The bootloader device should include:
Then flash the binary:
The normal non-bootloader Voyager device is different. Mine shows up as 3297:1977 during normal operation and 3297:0791 in DFU mode.
Tuning
These are the values I would tune first:
| Setting | My value | What it changes |
|---|---|---|
| AUTO_MOUSE_TIMEOUT | 1000 | How long the mouse layer stays on after trackpad activity |
| TRACKPAD_SCROLL_DIVIDER | 32.0f | Two-finger and hold-to-scroll sensitivity |
| TRACKPAD_TWO_FINGER_TAP_TERM_MS | 300 | How long a two-finger tap can last |
| TRACKPAD_TWO_FINGER_TAP_MOVE_THRESHOLD_SQ | 900 | How much motion is allowed before a tap becomes a scroll |
If right-click is hard to trigger, increase the tap term or movement threshold. If scroll starts when you meant to right-c
Discussion in the ATmosphere