One scrollbar, for every scroller in the app

Featurekamo-internal
Shipped
September 5, 2026 at 6:45 PM UTC
Author
Kamo
Commit
b1bc689

The terminal tool window had a scrollbar worth keeping — 32px, with a button for each END of the scrollback as well as the usual step arrows, because in a long document the two ends are not two more positions, they are the two places you actually want. The rest of the app had thirty-four hand-written copies of a lesser idea: 138 `::-webkit-scrollbar` declarations at six different widths (4, 6, 8, 12, 14, 20px), plus three global classes at a seventh. A guard test already existed because four chat windows had drifted to three widths; it could only ever fix a twelfth of the problem. The bar is now a component, painted OVER the element it scrolls rather than placed inside it. That is the decision everything else follows from. Wrapping each scroller to seat a bar beside the content would be a layout change in all 228 of them — flex children, grid children, `minHeight: 0` columns — and would still not reach the scrollports this app does not own: ag-Grid's, the DataGrid's, Lexical's. Painting over them is a layout change in none. The price is paid in overlayGeometry.ts: clipping by intersecting the clipping ancestors, stacking from the nearest ancestor's z-index plus one (which puts a page's bar under an open dialog and a dialog's bar over it, from one rule), and standing down when MUI marks the content behind a modal aria-hidden. Adoption is by marker class, not by sweeping the document for `overflow: auto`. Both reach every scroller; only one is debuggable, and only one keeps the bar out of every Select menu and 40px popper in the app. scripts/codemod-kamo-scroll.mjs applied it — parsing with TypeScript's own parser and splicing at byte offsets, so formatting and CRLF survive — and stripped the declarations it made dead. It degrades, because it now hangs off a 250px emoji popover as well as a page: buttons and track at 140px or longer, track alone below that, nothing at all when the element is not overflowing. Horizontal is the same component with the icons turned a quarter turn rather than a second set that would drift. A scroller that wants NO bar — the collapsed nav rail, the console mastheads, the filmstrip, the profile rail on a phone — now says so once, with `data-kamo-scrollbar="off"`, instead of in the six different ways it used to be said. Only TerminalTool still names a scrollbar in CSS, to hide xterm's own. TerminalScrollbar is left as an adapter: an xterm buffer into the model, in LINES rather than pixels, and moves back out as xterm calls or the wheel reports tmux binds. It keeps the indeterminate grip for a scrollback that cannot say where it is, and the lit jump-to-end button for output below the fold. Guards: oneScrollbar.test.ts fails if anything outside globals.css styles a scrollbar, if the component's width and the stylesheet's token disagree, or if the layer's marker list and the stylesheet's hide list fall out of step — an element on one and not the other gets two bars or none, and both read as a rendering glitch rather than a missing line. scrollbarView.test.tsx drives the bar from the keyboard, which is the point of the buttons being buttons.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing