bubblezone: giving BubbleTea components their own mouse hit areas
helper utility for BubbleTea, allowing easy mouse event tracking
At a glance
- What is it?
- bubblezone is a Go helper for BubbleTea that wraps components in zero-width markers so a terminal app can tell which region the mouse is over. It trades a scan of every rendered frame for hit testing that scales with component nesting, and it ships a global manager so components need no injected dependency.
- Who is it for?
- bubblezone fits a BubbleTea application that has grown past a single view, where components have children and you need to know which one the mouse is over, and it costs you one Scan call in the root View plus a marker on each interactive component.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 8 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Nested components break naive mouse hit testing
BubbleTea and lipgloss let you build fast terminal interfaces by abstracting layout, colour and events, and BubbleTea also supports mouse events. Two levels of support exist: the basic events such as MouseButtonLeft, MouseButtonRight, MouseButtonWheelUp and MouseButtonWheelDown, and full motion tracking for hover and mouse movement.
For a single-component application where state lives in one place, that is enough. The difficulty appears as the application grows. Once components have children and those children have children, working out whether a MouseButtonLeft landed inside a given component means computing that component's position on screen, accounting for its parent, and then comparing. The documentation describes this as complicated and rather tedious.
That is the problem bubblezone solves, and the framing matters. This is not a general purpose input library and not a replacement for BubbleTea's mouse handling. It is one specific mechanism for answering one question: which component is under the pointer right now.
The audience is therefore narrow and identifiable. If you are building a single-view terminal tool with a couple of buttons, you do not need this. If you are building something with a tree of panels, each with its own scrollable list, hover highlighting and click handlers, and you have already written manual offset arithmetic, this is the piece that removes that arithmetic.
The repository reflects a mature small library rather than a growing one. The top level is manager.go, manager_global.go, scanner.go, zoneinfo.go and messages.go, with their test files, plus a Makefile, a .golangci.yaml and an _examples/ directory.
Zero-width markers and a scan of every frame
The mechanism has three parts, and the middle one is the trade-off.
First, you wrap components in zero-printable-width identifiers, so they do not affect lipgloss.Width() calculations. Second, a scan method wraps the entire application, stores the offsets of those identifiers as zones, and then removes them from the resulting output. Third, when a mouse event arrives you pass it down to all children, and each child checks whether the event falls within the bounds of its zone.
That check is the payoff. Because the zone boundaries are already known, deciding whether a click landed inside a component is a comparison rather than a calculation. The documentation shows an example where the author did not have to work out where the mouse was being clicked or which component was under it.
The cost is the scan. Because offsets only make sense relative to the current render, the zone information has to be rebuilt every time the view changes. The project states the design priority plainly: given that it has to process this information for every render, performance was a focus where possible.
The zero-width property is the other half of the design and deserves care. The markers do not impact width calculations when using lipgloss.Width(). If you are using len() instead, they will. That distinction decides whether the library is invisible to your layout or not, and it is the single most common way to get surprising output from this library.
The wrapping of the root View is what makes it work. In your root model you wrap the View() output in zone.Scan(), which registers and monitors all zones and strips the ANSI sequences involved, so the markers never reach the terminal.
A global manager or an injected one
There are two ways to hold the zone manager, and the choice is about how much plumbing your component tree can tolerate.
The global manager is initialised with NewGlobal(), and you then use zone.<method> directly. The non-global alternative is New(). The documentation notes that the global manager is optional and is for when you have full access to all components, so you do not have to inject it as a dependency into all of them.
That is the practical difference. Injected means each component holds a reference, which is explicit and testable, but every constructor and every struct field has to carry it. Global means the components reach for a package level singleton, which is less code but couples them to a package level variable.
The global manager also has a lifecycle. The usage example calls zone.NewGlobal() inside main and notes that if the UI will be closed at some point and the application will still run, you should use zone.Close() to stop all background workers, with the call deferred.
func main() {
// [...]
zone.NewGlobal()
// If the UI will be closed at some point and the application will still run,
// use zone.Close() to stop all background workers:
// defer zone.Close()
//
// [...]
//
// Initialize your application here.
}Background workers are the reason this matters. The manager runs work in the background to track zones and messages, so a long-lived process that never calls Close leaves those goroutines running after the interface is gone.
Adding the dependency and the v2 module path
The module is published under the v2 path, which matters at the import site as well as the fetch.
go get -u github.com/lrstanley/bubblezone/v2@latestThe import in the usage example is aliased, so the package name and the import path do not have to agree.
import (
// [...]
zone "github.com/lrstanley/bubblezone/v2"
)The go.mod file shows the current dependency set, and it is worth reading before you pin a version. The module path is github.com/lrstanley/bubblezone/v2, it requires Go 1.24.2, and it depends directly on charm.land/bubbletea/v2 at v2.0.0, charm.land/lipgloss/v2 at v2.0.0 and github.com/mattn/go-runewidth at v0.0.20.
That the BubbleTea and lipgloss dependencies live at charm.land rather than github.com/charmbracelet is the visible sign of the v2 generation of those libraries, and it is the main compatibility fact for an adopter. The indirect dependencies list is longer and includes the colour profile package, ultraviolet, several x/ansi, x/term and x/windows packages, go-colorful, cancelreader, uniseg and a terminfo package.
go-runewidth in the direct dependencies is the mechanism behind the zero-width claim. Markers have to occupy zero printable columns, and that means measuring them the way a terminal measures characters rather than counting bytes.
The repository has no GitHub releases, so there is no tag history to consult in the releases list, though the project links a semantic version tag badge. The last push was on 2026-10-15, so the tree is being worked on.
The tips section is where the real usage knowledge lives
The documentation has a tips section, and its headings describe the situations that actually cause trouble.
Overlapping markers is the first. Two zones that overlap in screen space make the hit test ambiguous, and the documentation has a dedicated section on how to handle that.
The second tip is about lipgloss.Width, which is the same distinction described earlier: markers are invisible to the lipgloss width calculation and visible to len(). If a layout breaks in a way that looks impossible, this is often why.
MaxHeight and MaxWidth is the third, and it concerns components that clip their content. A clipped view renders fewer characters than the component claims to occupy, so a zone sized to the logical dimensions rather than the rendered ones will report hits in areas the user cannot see.
Only scan at the root model is the fourth. Since zone.Scan() in the root View is what registers and monitors all the zones and strips the sequences from the output, scanning in more than one place would double register or fight over the same offsets. The rule is one scan, at the root.
Organic shapes is the fifth, and it is the most interesting limitation to reason about. A zone is a rectangle. A component that is not rectangular, such as a circular gauge, a diagonal divider or a rounded panel, has hit areas that a bounding box does not describe accurately. A click in the corner of the bounding box is inside the zone but not on the visible shape.
For a serious application this is the point where you stop and decide whether the component model is too free-form for rectangular hit testing.
Development workflow, performance tooling and the licence
The Makefile shows how the project is worked on and gives an idea of the engineering effort behind the performance claim.
The test target runs the suite with the race detector, three repetitions and multiple CPU counts, both for the library and for the examples directory.
test:
GORACE="exitcode=1 halt_on_error=1" go test -v -race -timeout 3m -count 3 -cpu 1,4 ./...Running the same tests on one CPU and on four is the detail that matters for a library with background workers, and repeating three times catches flakiness that a single run would hide.
There is a separate fuzz target, go test -fuzz=FuzzScan -timeout=1m, aimed at the scanner, which is the right place to fuzz since it parses rendered output. Benchmarks are a separate target with go test -bench=., and the pprof target writes memory and CPU profiles and opens the pprof web interface on port 8081.
That is a well equipped setup for a library whose selling point includes being fast, and the presence of a benchmark directory in the repository top level fits the same story.
Other targets handle dependencies and housekeeping. fetch runs go mod download and go mod tidy, up upgrades both the root module and the examples. The license target pipes a script from the maintainer's own host to add or check licence headers, which is a slightly different approach from pinning a tool version.
The licence field on the repository says MIT, which is a permissive licence with attribution requirements and no copyleft obligations. This is not legal advice, but for a library you vendor into a terminal application the terms are conventional and unobtrusive.
The project also carries a .golangci.yaml for lint configuration and a .editorconfig, and its documentation links a Discord chat, GitHub discussions with a question category, and separate issue labels for bugs and enhancement requests, which is the ordinary shape of a library that wants contributions.
Editorial conclusion
bubblezone fits a BubbleTea application that has grown past a single view, where components have children and you need to know which one the mouse is over, and it costs you one Scan call in the root View plus a marker on each interactive component. It does not fit a single-panel terminal app, where the built-in basic mouse events are already enough, and it is the wrong tool if you cannot accept a scan on every render, because the whole model is that the zone offsets are recomputed each frame. Before adopting it, check your lipgloss usage, since the markers are invisible to lipgloss.Width() but not to len(), and read the tips on overlapping markers and on scanning only at the root model before you nest zones inside zones.
Frequently asked questions
What problem does bubblezone solve in a BubbleTea app?
When a BubbleTea application has nested components, working out whether a mouse event landed inside a given component means computing offsets by hand. Bubblezone lets you wrap components in zero-printable-width identifiers and check the event against a zone's bounds instead of calculating positions yourself.
How do I install bubblezone?
The documented command is go get -u github.com/lrstanley/bubblezone/v2@latest. The import path also carries the v2 suffix, so the package is normally aliased, for example zone "github.com/lrstanley/bubblezone/v2".
Does bubblezone affect my layout width?
The markers are designed not to impact width calculations when using lipgloss.Width(). The documentation notes that if you are using len() instead, the markers will affect the result.
When should I call zone.Scan()?
You wrap the View() output of your root model in zone.Scan(), which registers and monitors all the zones and strips the ANSI sequences from the output. The documentation tips include a section titled Only scan at the root model, so it should be done in one place.
What does the global zone manager change?
Bubblezone supports either a global manager initialised with NewGlobal(), which lets you call zone.<method> directly without injecting a dependency into every component, or a non-global one created with New(). The global manager also runs background workers, so zone.Close() should be called if the UI closes while the application keeps running.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/lrstanley-bubblezone)Was this useful?