Charm Bubbletea: Build Terminal UIs in Go
PROGRAMMING LANGUAGES March 22, 2026, 11:30 a.m.

Charm Bubbletea: Build Terminal UIs in Go

Charm Bubbletea has quickly become the go‑to framework for building terminal user interfaces (TUIs) in Go. It blends the simplicity of the Elm architecture with the power of modern Go, letting you craft interactive, responsive CLIs without wrestling with low‑level terminal escape codes. In this article we’ll explore the core concepts, walk through two practical examples, and share pro tips to keep your TUIs buttery smooth.

Getting Started: Installing Bubbletea

The first step is to pull the library into your Go module. Bubbletea lives under the github.com/charmbracelet/bubbletea import path, and you’ll typically want the lipgloss and bubbles sub‑packages for styling and ready‑made widgets.

go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
go get github.com/charmbracelet/bubbles

Once the dependencies are in place, create a new main.go file and import the packages. The boilerplate looks a bit like a typical Go program, but you’ll also define a model that holds UI state and a handful of methods that drive the update‑render loop.

The Elm‑Inspired Architecture

Bubbletea’s design mirrors the Elm architecture: a model for state, a view function that renders the UI, and an update function that processes messages (events). This separation keeps your code tidy and makes reasoning about UI flow straightforward.

Model

The model is just a Go struct. Anything you need to keep track of—selected items, input buffers, loading flags—belongs here. Because Go structs are cheap, you can freely embed other structs from the bubbles library.

Msg

Messages are simple Go types that signal an event. They can be built‑in like tea.KeyMsg for key presses, or custom structs for domain‑specific events such as “data fetched”. The update function pattern‑matches on the message type and mutates the model accordingly.

Update

The update function receives a tea.Msg and returns the updated model plus an optional command (tea.Cmd) that can trigger asynchronous work. This is where the magic of non‑blocking I/O lives; you can fire off HTTP requests, timers, or background goroutines without freezing the UI.

View

The view function takes the model and returns a string containing the terminal markup. Bubbletea doesn’t dictate a templating language—just return a plain string, optionally styled with lipgloss. The framework handles the diffing and redraw.

Pro tip: Keep your view pure. Never modify the model inside View; side‑effects belong in Update or commands.

Example 1: A Simple Todo List

Todo lists are the classic “hello world” for TUIs. They showcase input handling, list navigation, and dynamic rendering—all core Bubbletea features. Below is a minimal, fully functional Todo app.

Full Code

package main

import (
	"fmt"
	"os"
	"strings"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

type item struct {
	title string
	done  bool
}

type model struct {
	cursor int      // which item is selected
	items  []item   // list of todos
	input  string   // current text entry
	mode   string   // "list" or "input"
}

// Messages for switching modes
type addItemMsg struct{}
type quitMsg struct{}

func initialModel() model {
	return model{
		items: []item{},
		mode:  "list",
	}
}

// Update handles key presses and custom messages
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.KeyMsg:
		switch m.mode {
		case "list":
			switch msg.String() {
			case "ctrl+c", "q":
				return m, tea.Quit
			case "j", "down":
				if m.cursor < len(m.items)-1 {
					m.cursor++
				}
			case "k", "up":
				if m.cursor > 0 {
					m.cursor--
				}
			case "x":
				if len(m.items) > 0 {
					m.items[m.cursor].done = !m.items[m.cursor].done
				}
			case "a":
				m.mode = "input"
				m.input = ""
			}
		case "input":
			switch msg.Type {
			case tea.KeyRunes:
				m.input += string(msg.Runes)
			case tea.KeyBackspace, tea.KeyDelete:
				if len(m.input) > 0 {
					m.input = m.input[:len(m.input)-1]
				}
			case tea.KeyEnter:
				if strings.TrimSpace(m.input) != "" {
					m.items = append(m.items, item{title: m.input})
				}
				m.mode = "list"
			case tea.KeyEsc:
				m.mode = "list"
			}
		}
	}
	return m, nil
}

// Styling helpers
var (
	focusedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00"))
	doneStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Strikethrough(true)
)

// View renders the UI
func (m model) View() string {
	if m.mode == "input" {
		return fmt.Sprintf(
			"%s\n\nPress  to add,  to cancel.\n",
			focusedStyle.Render("> "+m.input),
		)
	}

	var b strings.Builder
	b.WriteString(\"Your Todo List (press 'a' to add, 'x' to toggle, 'q' to quit)\\n\\n\")

	if len(m.items) == 0 {
		b.WriteString(\"  -- nothing yet --\\n\")
	} else {
		for i, it := range m.items {
			cursor := \"  \"
			if m.cursor == i {
				cursor = \"> \"
			}
			title := it.title
			if it.done {
				title = doneStyle.Render(it.title)
			}
			if m.cursor == i {
				title = focusedStyle.Render(title)
			}
			b.WriteString(fmt.Sprintf(\"%s%s\\n\", cursor, title))
		}
	}
	return b.String()
}

func main() {
	p := tea.NewProgram(initialModel())
	if err := p.Start(); err != nil {
		fmt.Println(\"Error:\", err)
		os.Exit(1)
	}
}

The program starts in “list” mode, showing existing items. Press a to switch to an input prompt, type the task, and hit Enter to add it. x toggles completion, and q quits. The model cleanly separates list state from input state, making the flow easy to extend.

Real‑World Adaptation

In production you’d likely replace the in‑memory slice with a persistent store (SQLite, JSON file, or a remote API). Because Bubbletea commands are just functions returning tea.Msg, you can spin up a goroutine that reads/writes data and then send a custom message back to the UI when the operation finishes.

Pro tip: Use tea.Batch to combine multiple commands (e.g., save to disk and refresh the view) without blocking the main loop.

Example 2: A Real‑Time Stock Ticker

Now let’s build something a bit more dynamic—a terminal stock ticker that streams price updates every second. This showcases asynchronous commands, timers, and the bubbles spinner component for loading states.

Dependencies

We’ll need the spinner widget and a tiny HTTP client. Install the spinner package:

go get github.com/charmbracelet/bubbles/spinner

Code Walkthrough

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/lipgloss"
)

// Simple struct matching the JSON payload from a mock API
type quote struct {
	Symbol string  `json:"symbol"`
	Price  float64 `json:"price"`
}

// Model holds spinner, latest quote, and a ticker channel
type model struct {
	spinner   spinner.Model
	quote     quote
	ready     bool
	quit      bool
	err       error
}

// Msg types
type tickMsg time.Time
type quoteMsg struct{ q quote }
type errMsg struct{ err error }

// Init starts the spinner and the ticker
func (m model) Init() tea.Cmd {
	m.spinner = spinner.New()
	m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(\"#FFA500\"))
	return tea.Batch(m.spinner.Tick, tick())
}

// tick creates a channel that fires every second
func tick() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		return tickMsg(t)
	})
}

// fetchQuote performs a non‑blocking HTTP request
func fetchQuote() tea.Cmd {
	return func() tea.Msg {
		resp, err := http.Get(\"https://api.example.com/price?symbol=GOOG\")
		if err != nil {
			return errMsg{err}
		}
		defer resp.Body.Close()
		var q quote
		if err := json.NewDecoder(resp.Body).Decode(&q); err != nil {
			return errMsg{err}
		}
		return quoteMsg{q}
	}
}

// Update handles timer ticks, spinner ticks, and network responses
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.KeyMsg:
		if msg.String() == \"ctrl+c\" || msg.String() == \"q\" {
			m.quit = true
			return m, tea.Quit
		}

	case tickMsg:
		// Every tick we fire an async HTTP request
		return m, tea.Batch(fetchQuote(), tick())

	case quoteMsg:
		m.quote = msg.q
		m.ready = true
		return m, nil

	case errMsg:
		m.err = msg.err
		return m, nil

	case spinner.TickMsg:
		var cmd tea.Cmd
		m.spinner, cmd = m.spinner.Update(msg)
		return m, cmd
	}
	return m, nil
}

// View renders spinner while loading, then the price
func (m model) View() string {
	if m.err != nil {
		return fmt.Sprintf(\"Error: %v\\nPress q to quit.\", m.err)
	}
	if !m.ready {
		return fmt.Sprintf(\"Fetching price… %s\\nPress q to quit.\", m.spinner.View())
	}
	return fmt.Sprintf(
		\"%s %s: $%.2f\\nPress q to quit.\",
		lipgloss.NewStyle().Bold(true).Render(\"▶\"),
		m.quote.Symbol,
		m.quote.Price,
	)
}

func main() {
	p := tea.NewProgram(model{})
	if err := p.Start(); err != nil {
		fmt.Println(\"Failed to start:\", err)
		os.Exit(1)
	}
}

The ticker starts a spinner while the first request is in flight. Each second, a new fetchQuote command runs concurrently; when the response arrives it sends a quoteMsg back to Update, which stores the data and triggers a redraw. The UI never blocks, even if the network latency spikes.

Extending the Ticker

Real‑world stock dashboards often track multiple symbols, allow the user to add or remove tickers, and display historical sparklines. All of those extensions fit naturally into Bubbletea’s model:

  • Multiple symbols: replace quote with a slice of quotes and render each line in View.
  • User input: add a small input mode (similar to the Todo example) to let users type ticker symbols.
  • Sparklines: use the bubbles chart component or draw Unicode block characters manually.
Pro tip: When you have many concurrent network calls, throttle them with a semaphore (channel of size N) to avoid overwhelming the API and to keep the UI responsive.

Styling with Lipgloss

Bubbletea deliberately leaves rendering as plain strings, which is where lipgloss shines. It provides a chainable API for colors, borders, padding, and alignment, all of which work on any terminal that supports ANSI escape codes.

Basic Example

box := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("#FF5F87")).
    Padding(1, 2).
    Width(40).
    Align(lipgloss.Center)

content := box.Render("Hello, Bubbletea!")
fmt.Println(content)

The rendered box will keep its width even when the terminal resizes, thanks to Lipgloss’s automatic re‑flow. Combine multiple styles by nesting them or by using lipgloss.JoinVertical and JoinHorizontal to build complex layouts.

Responsive Layouts

For dashboards you often need a grid that adapts to the terminal size. Query the size via tea.WindowSizeMsg in Update, store the width/height in the model, and recompute the layout each render. Because the view is pure, you can safely recalculate without side effects.

Pro tip: Cache expensive layout calculations in the model and only recompute when WindowSizeMsg changes. This avoids unnecessary work on every tick.

Testing Your TUI

Bubbletea ships with a testing harness that lets you simulate messages and assert on the resulting model state or rendered output. This is invaluable for regression‑proofing complex interactions.

Simple Test

func TestTodoAddItem(t *testing.T) {
    m := initialModel()
    // Simulate pressing "a" to enter input mode
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
    // Type "Buy milk"
    for _, r := range []rune{'B','u','y',' ','m','i','l','k'} {
        m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
    }
    // Press Enter
    m, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
    if len(m.items) != 1 || m.items[0].title != "Buy milk" {
        t.Fatalf("item not added correctly: %+v", m.items)
    }
}

The test feeds a sequence of key messages into Update and verifies the model after the simulated user actions. Because the view is pure, you can also compare m.View() against an expected string for snapshot testing.

Performance Considerations

Even though Bubbletea is written in pure Go, large TUIs can still suffer from flicker or high CPU usage if you redraw too often. Here are a few guidelines:

  • Debounce rapid updates: If you receive a burst of messages (e.g., log tailing), batch them with tea.Batch and only redraw after a short pause.
  • Avoid heavy computation in Update: Offload CPU‑intensive work to a goroutine and send the result back as a message.
  • Use tea.WithAltScreen(): Running in the alternate screen buffer prevents the terminal from scrolling with every render, giving a cleaner experience.
Pro tip:
Share this article