Kinetip — Making a Pen Tablet Feel Native on macOS
I run my Mac with a pen tablet instead of a mouse. The vendor driver kept getting in the way, so I wrote my own. Here's the input path from tablet to screen, and what keeping a realtime driver alive taught me.
I run my Mac with a pen tablet instead of a mouse or trackpad. All day, for everything: code, documents, windows, browsing. Not drawing. Work. A pen is kinder to my hand, and once absolute positioning rewires your brain, a mouse feels clumsy.
The problem is that macOS never really wanted me to work this way. The vendor driver buries everything in per-model jargon, scrolling is an afterthought, the pointer fights you when it’s near a button, and it can quietly stop working after the machine sleeps. For a device I touch thousands of times a day, those interruptions add up.
So I wrote my own driver: Kinetip. It turns pen input into pointer, drag, pan, and scroll-with-inertia on macOS, and after months of daily use it’s more reliable than what came with the hardware.
It started as a hack
The first version wasn’t native at all. I wrote a small OpenTabletDriver plugin (hold a pen button, drag to scroll) to test whether pen-button scrolling was even usable. It was. It also made clear that the idea deserved better than a plugin sitting on top of a cross-platform stack.
I wanted something built around how macOS actually behaves, not a portability layer. Kinetip is that rewrite from scratch. Nothing carried over except the belief that a pen can be a real pointer on this platform.
How a pen stroke becomes a scroll
Before the war stories, here’s the shape of the system, because every decision after this falls out of it. I’ll follow one contact from the tablet surface to the screen.
A USB report comes off the tablet and gets decoded into a PenSample in raw device counts. A small clock model, DeviceScanClock, maps the tablet’s own scan-time counter onto the host’s monotonic clock, because the two run on unrelated bases and drift apart. The sample is pushed through the active-area transform into screen coordinates, pressure and tilt are normalized, and it becomes a PenEvent.
From there it reaches the part I care about most: a deterministic gesture reducer, a plain state machine, that turns a stream of PenEvents into InjectionCommands (move, down, drag, scroll, momentum). Scroll commands run through an inertia simulator on a ticker, and an event sink synthesizes real CGEvents and posts them to the window server.
One realtime thread owns that whole spine. It never allocates, never takes a lock, and never calls into accessibility or SwiftUI. Anything slow (asking the accessibility API what’s under the pen, sampling a scroll view’s position) runs on its own queue and hands back a small value snapshot the realtime thread reads when it’s ready. That single constraint is why the driver stays smooth. It’s also, as it turned out, where the two worst bugs lived.
The one thing it has to get right
If Kinetip does a single thing well, it’s context-aware scrolling with inertia.
On pen-down, the off-thread prober hit-tests the point against the system-wide accessibility tree and walks up the parent chain looking for a scrollable ancestor (an AXScrollArea, an AXWebArea, a list or a table). It gets a 60 ms budget. If it can’t answer in time, the gesture falls back to plain pointer behavior instead of stalling the pen. The answer comes back as a ContextClass, and the reducer branches on it: scrollable content turns pen movement into high-resolution scrolling with a momentum flick when you let go, the same physical feel as a trackpad; a button or a slider stays a pointer and won’t drag the page out from under you.
That branch is the whole trick. You stop thinking about modes, because the tablet does the sensible thing based on where you are. Underneath, the pipeline stays honest about the hardware: pressure, tilt, eraser identity, and proximity are normalized and posted as real tablet events, so apps that understand a pen still see one.

What keeping it alive taught me
The hard failures were never in the happy path. They lived in the seams — sleep, relaunch, a gesture that lands on the wrong window — and every one of them pushed back on the same architectural rule instead of asking for a clever patch.
The worst was a scroll that would die and stay dead until I reloaded the config. Clicks worked, selection worked; only scrolling was gone. Underneath, the engine was making a real-time decision against a clock that had quietly stopped advancing, so a timing gate that should open for a fraction of a second read as permanently shut. The fix wasn’t a special case — it was teaching the engine to rebuild its timing assumptions whenever the device goes quiet and comes back. The lesson outlasted the bug: on the realtime path, anything that can go stale eventually will, and the design has to assume it rather than trust it.
A second failure hid behind that one and made the architecture’s case better than any diagram could. A background probe occasionally hit-tested Kinetip’s own window and froze the whole engine by re-entering the UI layer from the wrong thread. That is the entire reason for the “realtime thread never touches the UI” rule, demonstrated in a single freeze. The safe version bails the instant a point is over one of our own windows, before any slow call — and there’s a test named after the lesson so I can’t quietly lose it again.
The fling that fought the rubber band
Flick to the bottom of a page and macOS rubber-bands. Kinetip used to keep shoving momentum into the wall, still ticking away against a page that wasn’t moving. The fix runs off the realtime path: a sampler reads the scroll position at a bounded cadence and reports whether the content is still advancing, pinned at an edge, or unknown. Pinned ends the coast; unknown keeps it going. It fails open by design — no reading, keep scrolling — for the same reason as everything else here: the pen should never pay for the driver’s uncertainty.
Inertia you can pump
Momentum should feel like a phone. Flick a few times in the same direction and the scroll should build. So the inertia model runs an exponential decay with an eased tail, and a new flick in the same direction adds its velocity to whatever is still coasting, clamped to a cap you set and ramped in over a few frames so it never jumps. There’s opt-in smoothing for the first fling too, but I kept it gated: the one thing I was never willing to trade was the crispness of a direct drag. Latency to feel smoother is a bad deal.

Features that came out of my own annoyance
I’m the user, so the feature list is mostly a record of things that bothered me.
Kinetip can mute its sound feedback during calls. Instead of special-casing one app, it watches for anything using the microphone, so my click and scroll ticks never leak into a meeting. The pen keeps working normally the whole time — only the audio cues go quiet. It’s one toggle.
It also keeps anonymized usage insights: a coarse heatmap of where the pen spends its time on screen, plus dwell, preferred tilt, and click and scroll hotspots, over today, the last week, or the last month. It aggregates onto a grid; no strokes or exact paths are stored.
And it has optional sound feedback, fully synthesized with no audio files, for clicks, drags, and scrolling. Active scrolling and momentum coasting are driven by a detent model, so the ticks line up with the motion instead of drifting off it.
A diagnostics page, not a status light
Most vendor utilities give you one green dot and call it a day. Kinetip has a Diagnostics tab that lays the whole pipeline out at once: whether the engine is running live, how many reports came in raw versus decoded versus ignored, the active tablet surface, and separate checks for the three permissions it actually needs — Input, Context, and Posting. Under that it pins the boring facts that make a bug report useful: config file, schema version, build, macOS, connected displays, an on-demand latency snapshot, and a recent log. When the pen misbehaves I don’t guess — I open this page, see which stage went quiet, and if I do have to ask for help, the whole screen is the report.

Fast on the small chips
I did an optimization pass aimed at the base Apple Silicon machines (M1 through M4, non-Pro). One rule drove all of it: the realtime callback only ever gets lighter. It does normalization, gesture reduction, and posting with no allocations, no locks, and no accessibility or UI calls. Multicore is allowed to help, but only off that path. Telemetry, context probes, and scroll sampling all live on other queues and feed back read-only snapshots. A pen driver that adds latency to look optimized has missed the point of existing.
About the “vibecoded” part
I’ll be upfront: Kinetip was largely vibecoded, built fast and iteratively with heavy AI assistance. I’m not hiding that, and I’m also not making it the story. The tooling that produced the code isn’t the interesting part. What’s interesting is that the code solves a real, daily, physical problem I have. AI is what made it feasible for one person to ship a native driver with over 800 tests across 99 suites, plus fuzzers that throw 100,000+ mutated inputs at the gesture reducer and the HID parser on every run. Whether it’s a good tool gets decided by my hand on the pen, not by how the commits happened.
Where it landed
I use Kinetip every day, for hours. The interruptions that used to break my focus are gone, or have become boring: it comes back after sleep, scroll doesn’t die, the pointer stops fighting me near buttons, and momentum settles at the end of a page instead of grinding into it. Most of the time the pen just behaves like part of the Mac. When it doesn’t, I own a driver whose failure modes I can read in a log and fix. That’s the part the box driver never gave me.
Want to try it?
I’m weighing a commercial release for Kinetip. For now, if you want to test it, email me. Working from a Wacom pen all day is genuinely comfortable — I know there aren’t many of us doing it, but I think this could be useful to a lot more people than currently realize it’s an option.