Porting Go's Bubbles to TypeScript: 15 TUI Components, Zero Dependencies
Bubble Tea gives you the event loop. Lipgloss gives you styled text. But to build a real TUI application, you need components — text inputs that handle cursor movement, viewports that scroll, tables with selectable rows, lists with fuzzy filtering.
In Go, Charmbracelet's Bubbles library provides all of this. Fifteen components, each following the Elm Architecture, each composable with the others. It's the component library that makes Charm's ecosystem practical.
Now it's available in TypeScript.
I – What Shipped
@oakoliver/bubbles — 15 pre-built TUI components for TypeScript. Text input, text area, viewport, table, list, spinner, progress bar, help generator, paginator, file picker, timer, stopwatch, cursor, and a keybinding system that ties them all together.
npm install @oakoliver/bubbles @oakoliver/lipgloss @oakoliver/bubbletea
Zero runtime dependencies. Peer dependencies on lipgloss (styling) and bubbletea (event loop). 256 tests, 589 expects, 11 test files. 11,738 lines of TypeScript across 15 component modules plus 3 internal utilities.
This is the fourth of five Charmbracelet ports. Lipgloss, Glamour, and Bubbletea are already published. Glow follows.
II – The Component Inventory
Every component follows the same pattern: create with a factory function, call update(msg) with messages, call view() to render.
Input components. TextInput handles single-line text entry with cursor movement, echo modes (normal, password, none), character limits, placeholder text, width constraints, and an inline suggestion/completion system. TextArea handles multi-line editing — word wrapping, soft wrap, line numbers, configurable prompts, cursor line highlighting, and vertical scrolling via an embedded viewport.
Display components. Viewport is the scrollable content viewer — ANSI-aware text cutting, soft wrapping, horizontal scrolling, gutter functions for line numbers. Table builds on viewport to add headers, selectable rows, column widths, and focus styles. Progress renders an animated progress bar with spring physics — you set a target percentage and the bar physically animates toward it using a critically damped spring. Spinner provides 12 built-in animation styles.
Navigation components. List is the most complex component — fuzzy filtering with match highlighting, pagination, a status bar, spinner integration for async loading, help integration, and a delegate pattern that lets you completely customize how items render. Paginator handles page navigation with Arabic ("1/5") or Dots ("● ○ ○") display. FilePicker provides file system browsing with Node.js fs.
Utility components. Help auto-generates help text from keybindings — short mode shows a single line, full mode shows a multi-column layout. Key is the keybinding system: Binding objects with matched keys, help text, and enabled state. Cursor manages virtual cursor blinking. Timer counts down, Stopwatch counts up.
III – The Hard Parts
Spring Physics from Scratch
The progress bar doesn't just jump to its target — it animates. Charmbracelet uses their harmonica library for this, which implements a critically damped spring using the analytical solution.
I ported harmonica as an internal module. The spring update function takes the current position and velocity, the target position, and a time delta, then returns the new position and velocity using the exponential decay formula for critical damping:
export function springUpdate(
s: Spring, pos: number, vel: number,
target: number, dt: number,
): [number, number] {
const delta = pos - target;
const exp = Math.exp(-s.angularFrequency * dt);
const newPos = target + exp * (delta + (vel + s.angularFrequency * delta) * dt);
const newVel = exp * (vel * (1 - s.angularFrequency * dt)
- s.angularFrequency * s.angularFrequency * delta * dt);
return [newPos, newVel];
}
The progress component creates a spring, stores position and velocity, and updates them on every frame tick. The visual result is a bar that overshoots slightly and settles — it feels physical.
Fuzzy Filtering Without Dependencies
The List component uses fuzzy matching for its filter. Go's Bubbles imports sahilm/fuzzy. I couldn't import anything.
The algorithm walks the pattern characters against each target string, tracking which character indices matched. It scores based on consecutive matches (bonus), word boundary matches (bonus), and match position (earlier is better). Results are sorted by score descending.
function fuzzyMatch(pattern: string, targets: string[]): Rank[] {
const results: Rank[] = [];
const pLower = pattern.toLowerCase();
for (let ti = 0; ti < targets.length; ti++) {
const target = targets[ti];
const tLower = target.toLowerCase();
// Walk pattern chars, find matches...
}
return results.sort((a, b) => b.score - a.score);
}
This runs synchronously — no async, no workers. For a TUI list component where targets are typically hundreds of items, it's fast enough.
ANSI-Aware String Operations
The viewport needs to cut strings at exact visual positions while preserving ANSI escape sequences. If you have "\x1b[31mHello\x1b[0m World" and need to cut at visual position 3, you need "\x1b[31mHel\x1b[0m" — not a naive substring.
I ported the ansi.Cut function which tracks a state machine for escape sequences (CSI, OSC, APC, DCS, SOS, PM) while counting visible character widths. When cutting, any open ANSI sequences are properly closed in the output.
The TextArea — 1,534 Lines
TextArea was the most complex single component. The Go source is 1,794 lines. My TypeScript port is 1,534.
It maintains a 2D grid (string[][]) of characters, handles word wrapping by splitting lines at visual boundaries, manages a viewport for scrolling, tracks cursor position through soft-wrapped lines, handles word-level navigation (alt+left, alt+right), word deletion (alt+backspace), clipboard-style line operations, and renders line numbers with proper alignment.
The trickiest part is maintaining the cursor position across soft-wrapped lines. When the user moves down, the cursor should remember its horizontal position from the original line, even if the next line is shorter. This requires tracking lastCharOffset and mapping it back through the soft-wrap grid on each vertical movement.
IV – Test Parity
I ported tests from every Go source file that had them:
| Module | Tests | Expects |
|---|---|---|
| key | 14 | 19 |
| runeutil | 17 | 17 |
| spinner | 17 | 17 |
| paginator | 19 | 21 |
| progress | 26 | 26 |
| help | 15 | 15 |
| viewport | 30 | 81 |
| table | 36 | 70 |
| textinput | 28 | 43 |
| textarea | 40 | 85 |
| list | 12 | 17 |
| Total | 256 | 589 |
The Go cursor test is a race condition test using goroutines — not applicable to single-threaded JavaScript, so it was skipped.
Some tests required adaptation. Go tests in the same package can access private fields directly. In TypeScript, I use bracket notation ((model as any)._row) to access private state for assertions. The behavior being tested is identical.
V – The Architecture Insight
Bubbles demonstrates why the Elm Architecture works so well for composable components. Each component is self-contained: it owns its state, handles its own messages, and renders its own view. Composition is mechanical — the parent's update calls the child's update, the parent's view calls the child's view.
TextArea composes Viewport, Cursor, and the Keybinding system. List composes TextInput, Paginator, Spinner, and Help. Table composes Viewport. There's no prop drilling, no context, no state management library. Just update returning [model, cmd].
The keybinding system is the glue. Every component defines a KeyMap — a struct of Binding objects. The matches(msg, binding) function checks if an incoming key message matches any binding. Bindings carry help text, so the Help component can auto-generate documentation from any KeyMap.
This pattern — components as (state, msg) → (state, cmd) with composable keybindings — is genuinely good. It produces predictable, testable, debuggable interactive applications.
VI – What's Next
One port remains: Glow — the terminal markdown reader. It depends on all four libraries: Glamour for rendering, Lipgloss for styling, Bubbletea for the event loop, and Bubbles for the viewport and help components.
After Glow, the entire Charmbracelet TUI ecosystem will be available in TypeScript.
– Antonio