Go TUIs with Bubble Tea

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Bubble Tea is a Go framework for terminal user interfaces based on The Elm Architecture. A program is a model (a value that holds all the state), an Update function that receives messages (key presses, timer ticks, results of I/O) and returns the next model plus an optional command, and a View function that renders the model. The runtime owns the terminal: it switches it to raw mode, decodes the input, runs the commands, draws the frames and restores everything when the program exits. This page builds the Bookshelf shelf tui (a catalogue list, a details pane, a loans table and a search box) with Bubble Tea v2 and the libraries around it.

The page was written against Bubble Tea v2.1.0 (published 2026-10-08, the charm.land/bubbletea/v2 module, package name tea), Bubbles v2.2.1, Lip Gloss v2.0.6, Huh v2.0.3, Glamour v2.0.1 and VHS v0.12.1, with Go 1.27.3 (the Bubble Tea module declares go 1.26.0, Glamour declares go 1.26.7). The Go snippets on this page were not compiled or run while it was written, because no Go toolchain was available; they were checked against the published package documentation and, where the documentation is silent, against the library sources at the tags above, and every example is followed by the official page it derives from. Treat them as starting points to build with go build, not as verified binaries.

The concepts that apply to every TUI library (the input, update and render loop, raw mode, the cell buffer and layout) are on TUI Architecture & Concepts, and the escape sequences behind the features described here (alternate screen, SGR mouse reporting, bracketed paste) are on Escape Sequences & Terminal Capabilities. When a TUI is appropriate in the first place, and why a TUI must never be the only interface, is on TUIs vs. CLIs. The Go side of command-line programs (flags, subcommands, signals, golang.org/x/term) is on Command-Line Programs in Go, and the language itself in the Go section. A short Bubble Tea introduction sits on Go & Rust CLI Frameworks; this page goes much further.

The Charm modules and their import paths

Bubble Tea is one library of a family published by Charm. Since the v2 releases the family lives under the vanity domain charm.land, and the import paths changed with it.

Library v2 import path Version Role

Bubble Tea

charm.land/bubbletea/v2 (package tea)

v2.1.0

The runtime: model loop, messages, commands, input decoding, renderer

Bubbles

charm.land/bubbles/v2 (one package per component)

v2.2.1

Ready-made components: list, table, textinput, textarea, viewport, spinner, progress, paginator, help, key, filepicker, timer, stopwatch, cursor, tree

Lip Gloss

charm.land/lipgloss/v2

v2.0.6

Styles, colours, borders, layout (joining, placing), colour downsampling when printing

Huh

charm.land/huh/v2

v2.0.3

Forms and prompts; a huh.Form can run on its own or inside a Bubble Tea program

Glamour

charm.land/glamour/v2

v2.0.1

Markdown rendering for ANSI terminals

teatest

github.com/charmbracelet/x/exp/teatest/v2

untagged (a pseudo-version dated 2026-10-25 when this page was written)

Test helpers for tea.Model, in the experimental x/exp tree

VHS

command-line tool, github.com/charmbracelet/vhs

v0.12.1

Scripts a terminal session (a "tape") and records it as a GIF, video or text

The old paths (github.com/charmbracelet/bubbletea, github.com/charmbracelet/lipgloss, github.com/charmbracelet/bubbles) are the v1 modules. A program must not mix the two generations: a v1 Bubbles component does not implement the v2 tea.Model interface, and the Lip Gloss Color type changed shape (see the Lip Gloss section). The teatest module keeps the github.com/charmbracelet/x/exp/teatest/v2 path and has no tagged release, so go get resolves it to a pseudo-version.

A go.mod for the programs on this page:

module example.com/shelf-tui

go 1.27

require (
	charm.land/bubbles/v2 v2.2.1
	charm.land/bubbletea/v2 v2.1.0
	charm.land/huh/v2 v2.0.3
	charm.land/lipgloss/v2 v2.0.6
	charm.land/glamour/v2 v2.0.1
)

In practice go mod tidy writes the require block (and the indirect dependencies) for you; add the teatest module with go get github.com/charmbracelet/x/exp/teatest/v2@latest when you write the tests.

The Elm Architecture in Bubble Tea

Bubble Tea’s model interface has three methods.

type Model interface {
	Init() Cmd
	Update(Msg) (Model, Cmd)
	View() View
}
Concept Meaning in Bubble Tea v2

Model

Any value with the three methods above. It holds the whole state of the program. The usual choice is a struct with value receivers, so every Update returns a new copy

Init() tea.Cmd

Called once at start-up. It returns the first command (an initial load, a timer) or nil for "no I/O right now"

Update(tea.Msg) (tea.Model, tea.Cmd)

The only place where state changes. It receives one message, returns the next model and optionally a command

View() tea.View

A pure function from model to a tea.View. Since v2 it returns a struct, not a string (see below)

tea.Msg

Any value. Bubble Tea sends key, mouse, paste, focus and window-size messages; your own types carry the results of your commands

tea.Cmd

A function with the signature func() tea.Msg. The runtime runs it off the update path and delivers the returned message to Update; a nil command is a no-op

The flow of one interaction, from a key press to a redrawn screen, with a command that does I/O in between:

sequenceDiagram participant T as Terminal participant R as Bubble Tea runtime participant M as Model participant C as Cmd goroutine T->>R: key press bytes R->>M: Update(KeyPressMsg) M-->>R: next Model and Cmd loadBooks R->>C: run loadBooks in a goroutine R->>M: View() M-->>R: tea.View with content and flags R->>T: frame drawn by the renderer C-->>R: returns loadedMsg R->>M: Update(loadedMsg) M-->>R: next Model and nil Cmd R->>M: View() M-->>R: tea.View R->>T: updated frame

The renderer redraws at a limited frame rate (the WithFPS program option, with a default and a maximum), so the runtime may call View less often than it calls Update. View must therefore have no side effects and Update must not wait for the screen.

A first program

The smallest useful program for the Bookshelf scenario lets you tick books to take on loan. It is adapted from the grocery-list program in the official tutorial, using the v2 API.

package main

import (
	"fmt"
	"os"

	tea "charm.land/bubbletea/v2"
)

type model struct {
	books    []string
	cursor   int
	selected map[int]struct{}
}

func initialModel() model {
	return model{
		books: []string{
			"The Left Hand of Darkness",
			"A Canticle for Leibowitz",
			"The Dispossessed",
		},
		selected: make(map[int]struct{}),
	}
}

func (m model) Init() tea.Cmd {
	return nil // no I/O needed at start-up
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit
		case "up", "k":
			if m.cursor > 0 {
				m.cursor--
			}
		case "down", "j":
			if m.cursor < len(m.books)-1 {
				m.cursor++
			}
		case "enter", "space":
			if _, ok := m.selected[m.cursor]; ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = struct{}{}
			}
		}
	}
	return m, nil
}

func (m model) View() tea.View {
	s := "Which books do you want to borrow?\n\n"
	for i, b := range m.books {
		cursor := " "
		if m.cursor == i {
			cursor = ">"
		}
		checked := " "
		if _, ok := m.selected[i]; ok {
			checked = "x"
		}
		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, b)
	}
	s += "\nPress q to quit.\n"
	return tea.NewView(s)
}

func main() {
	if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
}

Three details of the v2 API are visible here. View returns tea.NewView(s) instead of a string. Key messages are tea.KeyPressMsg (not tea.KeyMsg, which is now an interface that covers presses and releases). And the space bar is named space in msg.String(), where v1 returned a literal space. The map in the model is shared between copies of the model, which is why the toggle works without returning a new map; a slice or a struct field would need to be copied explicitly.

Messages and commands

A command is how a program does anything that is not a pure state change: reading a file, calling shelfd, waiting for a timer. The runtime executes commands in goroutines (the source of handleCommands says so) and sends the returned message back to Update. A function that returns a message after it did its work is therefore all a command is:

type loadedMsg []book
type failedMsg struct{ err error }

func loadBooks(path string) tea.Cmd {
	return func() tea.Msg {
		data, err := os.ReadFile(path)
		if err != nil {
			return failedMsg{err}
		}
		var books []book
		if err := json.Unmarshal(data, &books); err != nil {
			return failedMsg{err}
		}
		return loadedMsg(books)
	}
}

Errors travel as ordinary messages: the command returns a message type that carries the error, and Update decides what to show. The package documentation notes that a command should not be used to send a message to another part of your program; that can almost always be done directly in Update.

Commands the package provides

Function Behaviour

tea.Batch(cmds ...Cmd) Cmd

Runs the commands concurrently, with no ordering guarantee about the results

tea.Sequence(cmds ...Cmd) Cmd

Runs the commands one at a time, in order (the v1 name was Sequentially)

tea.Tick(d time.Duration, fn func(time.Time) Msg) Cmd

Sends one message after d. To keep ticking, return another Tick from the Update that handles the previous one

tea.Every(d time.Duration, fn func(time.Time) Msg) Cmd

Like Tick, but aligned to the system clock (every minute on the minute)

tea.Quit, tea.Suspend, tea.Interrupt

Special commands (their functions return a tea.Msg, so they are used as tea.Quit, not tea.Quit()): exit the program, suspend it, or interrupt it. A tea.ResumeMsg arrives when a suspended program continues

tea.Println(args ...any), tea.Printf(template string, args ...any)

Print a line above the program; the output is not managed by the renderer and persists across redraws. Nothing is printed while the alternate screen is active

tea.ExecProcess(cmd *exec.Cmd, fn func(error) Msg), tea.Exec

Pause the program, run an interactive command (an editor or a shell) on the terminal, then resume and send the message built by fn

tea.RequestWindowSize, tea.RequestBackgroundColor

Ask the terminal for its size or background colour; the answers arrive as tea.WindowSizeMsg and tea.BackgroundColorMsg

Batch and Sequence both drop nil commands, and when exactly one command is left they return it directly, so it is safe to build a list with conditional entries. A typical Init starts a spinner and a load in parallel, and a later step chains work that must happen in order:

func (m model) Init() tea.Cmd {
	return tea.Batch(m.spin.Tick, loadBooks(m.path))
}

// after the user confirms a loan: write it, and only then reload the list
func confirmLoan(isbn, who string) tea.Cmd {
	return tea.Sequence(saveLoan(isbn, who), loadBooks("books.json"))
}

(m.spin.Tick is a method value of type func() tea.Msg, which is assignable to tea.Cmd; this is how the Bubbles spinner is started.) The sequence delivers the result of saveLoan before loadBooks runs.

Editing a note in $EDITOR

tea.ExecProcess is the documented way to hand the terminal to another interactive program. The program below the official example opens the user’s editor on the notes file of a book and reloads when it exits.

type editorFinishedMsg struct{ err error }

func editNotes(path string) tea.Cmd {
	editor := os.Getenv("EDITOR")
	if editor == "" {
		editor = "vim" // on Windows set EDITOR; there is no universal default
	}
	c := exec.Command(editor, path)
	return tea.ExecProcess(c, func(err error) tea.Msg {
		return editorFinishedMsg{err}
	})
}

Messages sent from outside the program

(*tea.Program).Send(msg) delivers a message from any goroutine, which is how a background listener (a watcher on the shelfd socket, for example) feeds the model. The WithFilter program option installs a function that sees every message before Update and may replace or swallow it (the package documentation gives it the signature func(Model, Msg) Msg); the repository’s prevent-quit example uses it to intercept the quit. What the official pages do not provide is a recipe for combining Send with an application that has several goroutines; keep the model itself on the Update path and send only messages.

Input messages

Bubble Tea v2 decodes the terminal input into typed messages. The encoding of the bytes themselves is on Escape Sequences & Terminal Capabilities.

Message Notes

tea.KeyPressMsg

A key press. msg.String() returns names such as up, enter, esc, tab, space, backspace, pgup, pgdown, ctrl+c and shift+tab, or the typed text; msg.Keystroke() always includes the modifiers. The type is type KeyPressMsg Key

tea.KeyReleaseMsg

A key release. It is only delivered when the terminal reports key event types (the Kitty keyboard protocol); a tea.KeyboardEnhancementsMsg tells the program what the terminal supports, and the view requests the features with KeyboardEnhancements

tea.KeyMsg

An interface satisfied by both of the above, with Key() and String(). Use it when you want presses and releases in one case

tea.PasteMsg, tea.PasteStartMsg, tea.PasteEndMsg

Bracketed paste. The pasted text is msg.Content; in v1 a paste was a KeyMsg with a Paste flag

tea.MouseClickMsg, tea.MouseReleaseMsg, tea.MouseWheelMsg, tea.MouseMotionMsg

Mouse events, each with Mouse() returning X, Y, button and modifiers. tea.MouseMsg is the interface covering all four. Mouse reporting is only on when the view asks for it

tea.WindowSizeMsg

Width and Height, sent once initially and then on every resize. A model that lays out components must handle it, and must not assume the size before the first message

tea.FocusMsg, tea.BlurMsg

Terminal window focus gained or lost, delivered when the view sets ReportFocus

tea.BackgroundColorMsg

The answer to tea.RequestBackgroundColor; msg.IsDark() tells a style sheet which palette to pick

The tea.Key struct has the fields Text (the printable text, empty for special keys), Mod (a tea.KeyMod bit set), Code (a rune such as tea.KeyEnter or 'a'), ShiftedCode, BaseCode (the key on a US PC-101 layout) and IsRepeat. The upgrade guide maps the v1 idioms to these fields:

v1 v2

msg.Type

msg.Code (a rune)

msg.Runes

msg.Text (a string)

msg.Alt

msg.Mod.Contains(tea.ModAlt)

tea.KeyRune

len(msg.Text) > 0

tea.KeyCtrlC

msg.String() equal to ctrl+c, or msg.Code == 'c' && msg.Mod == tea.ModCtrl

case " ": for the space bar

case "space":

tea.MouseButtonLeft, tea.MouseButtonWheelUp

tea.MouseLeft, tea.MouseWheelUp

Because the terminal is in raw mode while the program runs, Ctrl+C is delivered as a tea.KeyPressMsg, and the official tutorial handles ctrl+c explicitly; a program that ignores it has to be stopped another way. Interrupts that do arrive as signals end the run with tea.ErrInterrupted. Signal handling can be switched off with the WithoutSignalHandler and WithoutSignals options, and the general rules for signals are on POSIX Processes, Signals & I/O.

The declarative View: tea.View

In v1, terminal features were program options (WithAltScreen, WithMouseCellMotion) or commands (tea.EnterAltScreen, tea.SetWindowTitle). In v2 they are fields on the value that View returns, so the model describes the terminal state it wants on every frame and the runtime makes it so. A frame that stops asking for the alternate screen leaves it.

tea.View field Effect

Content string

The frame text, with ANSI styling. Set with tea.NewView(s) or v.SetContent(s)

AltScreen bool

Run on the alternate screen buffer (full-screen mode). Replaces WithAltScreen, EnterAltScreen and ExitAltScreen

MouseMode tea.MouseMode

MouseModeNone, MouseModeCellMotion (clicks, releases, wheel, drags) or MouseModeAllMotion (also motion without a button). Replaces WithMouseCellMotion, WithMouseAllMotion and the enable/disable commands

OnMouse func(msg MouseMsg) Cmd

A mouse callback on the view

ReportFocus bool

Deliver FocusMsg and BlurMsg

DisableBracketedPasteMode bool

Bracketed paste is on by default; set this to turn it off

WindowTitle string

The terminal window title (replaces SetWindowTitle)

Cursor *tea.Cursor

Show a real cursor: tea.NewCursor(x, y), with position, colour, shape and blink. nil hides it

BackgroundColor, ForegroundColor

color.Color values the program wants the terminal to use

ProgressBar *tea.ProgressBar

The terminal’s own progress indicator (tea.NewProgressBar(state, value), for terminals that support it)

ProgramStatus *tea.ProgramStatus

A status the program reports to the terminal; the package documentation lists the field without a longer description

KeyboardEnhancements

Opt in to richer keyboard reporting such as key-release events

A view that asks for the full-screen mode, the mouse and a title looks like this:

func (m model) View() tea.View {
	v := tea.NewView(m.render())
	v.AltScreen = true
	v.MouseMode = tea.MouseModeCellMotion
	v.WindowTitle = "shelf tui"
	return v
}

Inline programs and full-screen programs

A program without AltScreen draws inside the normal scrollback, below the prompt. When it exits, the last frame stays in the terminal history. That is the right choice for a short prompt such as a book picker or a confirmation, and it keeps the terminal output usable in a log. A full-screen program uses the alternate screen: the previous contents of the terminal are restored on exit and the program owns every cell, which suits a browser such as shelf tui.

Aspect Inline (no AltScreen) Full screen (AltScreen = true)

After the program exits

The last frame remains in the scrollback

The terminal returns to what it showed before

Output from the program (tea.Println)

Printed above the live frame and kept

Nothing is printed while the alternate screen is active

Typical shelf command

shelf pick (choose a book, print its ISBN)

shelf tui

Resize

The frame is redrawn at the new width

The whole screen is redrawn; the layout has to follow tea.WindowSizeMsg

An inline picker that must work in $(shelf pick) writes the TUI to a different stream than the result. Bubble Tea’s WithOutput option accepts any io.Writer, and the final model is returned from Run:

func main() {
	p := tea.NewProgram(newPicker(), tea.WithOutput(os.Stderr))
	final, err := p.Run()
	if err != nil {
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
	if m, ok := final.(picker); ok && m.choice != "" {
		fmt.Println(m.choice) // only the ISBN reaches stdout
	}
}

Drawing on standard error so that standard output carries only data follows the stream rules on Streams & Exit Codes. The official documentation lists WithOutput and WithInput but does not describe this particular combination, so test it in your target shells (it matters in particular that standard input is still the terminal).

Running the program

tea.NewProgram(model, opts...) returns a *tea.Program. Run() blocks until the program quits and returns the final model and an error. It replaces Start and StartReturningModel from v1.

Option or method Purpose

WithInput(io.Reader), WithOutput(io.Writer)

Replace standard input and output. Needed for tests, for sending the UI to standard error and for RequestBackgroundColor, whose reply must be readable

WithContext(ctx)

Cancel the program from outside; Run then returns an error wrapping tea.ErrProgramKilled

WithFPS(int)

The renderer’s frame rate (it has a default and a maximum)

WithWindowSize(w, h), WithColorProfile(p), WithEnvironment([]string)

Fix the initial size, the colour profile or the environment. Useful in tests and under tmux or CI

WithFilter(func(Model, Msg) Msg)

See each message before Update

WithoutRenderer()

Disable the renderer; output goes plainly to standard output. For running the same model non-interactively (logs, daemons)

WithoutSignals(), WithoutSignalHandler()

Do not install the program’s signal handling

WithoutCatchPanics()

Let panics propagate instead of being recovered and reported as tea.ErrProgramPanic

p.Send(msg), p.Quit(), p.Kill(), p.Wait(), p.Println(...), p.Printf(...)

Control the running program from other goroutines

The errors tea.ErrInterrupted (the program received an interrupt), tea.ErrProgramKilled and tea.ErrProgramPanic are declared in the package, and Run wraps them, so errors.Is distinguishes them. The runtime restores the terminal when the program exits normally or recovers from a panic; it cannot do so if the process is killed with SIGKILL, and a terminal left in raw mode needs reset or stty sane. The details of that restore are in Terminals, TTYs & Pseudo-Terminals.

Do not start a TUI when there is no terminal

Bubble Tea assumes a terminal. The README and the package documentation do not describe what a program does when standard input is a pipe or standard output is a file, so a command that offers both a TUI and a script-friendly mode should decide before it creates the program, with golang.org/x/term:

if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
	return runPlain(args) // print the same data as text or JSON, no TUI
}
return runTUI(args)

This is the same rule as in TTY, Colour & Interactivity, and shelf list stays a plain command either way.

Logging

The TUI owns standard output, so fmt.Println debugging corrupts the display. The README’s advice is to log to a file with tea.LogToFile("debug.log", "debug") when an environment variable such as DEBUG is set, and to follow it with tail -f debug.log. It also documents debugging with Delve in headless mode (dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .) and connecting with dlv connect.

Source: Bubble Tea v2 package documentation (Program, ProgramOption, Run) and README — Debugging.

Platform behaviour

The official README and package documentation do not contain a platform support table, so the following is limited to what they and the library source state.

Topic Linux macOS Windows

Terminal setup

The source has Unix terminal files (tty_unix.go, termios_unix.go) shared with the BSDs and other Unix systems

Same Unix files; termios_bsd.go covers the BSD family

Separate files (tty_windows.go, termios_windows.go, signals_windows.go) exist; the input setup puts the console in raw mode and enables ENABLE_VIRTUAL_TERMINAL_INPUT with SetConsoleMode. See Windows Console & Processes for the mechanics

Resize notification

The program listens for SIGWINCH and sends tea.WindowSizeMsg

Same

The source states that SIGWINCH is not implemented on Windows, so its Unix resize listener does nothing there; the documentation does not say how size changes are detected, so test resizing on Windows

Ctrl+C

A key press in raw mode, handled by the program

Same

The same message type; confirm the behaviour in your host (Windows Terminal, conhost)

Colour and key features

Depend on the terminal emulator; the runtime downsamples colours to the detected profile (WithColorProfile overrides it)

Same

Depend on the console host; modern Windows Terminal supports the sequences, legacy hosts may not

Terminal capabilities (the Kitty keyboard protocol for key releases, synchronized output, hyperlinks) vary more between emulators than between operating systems; the table on Cross-Platform Portability is the place to start.

Bubbles: components for the common widgets

Bubbles components are sub-models: each has Update(msg) (Model, tea.Cmd) and View() string. A parent model stores them as fields, forwards the messages it wants them to see, keeps the returned model and collects the returned command. The View of a component returns a plain string in v2; the parent embeds it in its own tea.View.

Package Component

list

A feature-rich list with titles and descriptions, optional filtering, pagination, a status bar, help and spinner. Items implement FilterValue() string; with DefaultDelegate they also implement Title() and Description()

table

A table with table.Column (title and width) and table.Row (a []string), scrolling and a selected row

textinput

A single-line field with placeholder, prompt, character limit, echo mode (for passwords), validation and suggestions

textarea

A multi-line field

viewport

A vertically scrollable area, with optional soft wrapping, horizontal scrolling, mouse wheel support and highlights

spinner

An animated indicator with presets such as Dot, Line, MiniDot, Pulse and Points

progress

A progress bar with optional animation and a colour gradient

paginator

Pagination logic with an optional dots or numeric indicator

help, key

key defines key bindings; help renders a one-line or full help view generated from them

filepicker, timer, stopwatch, cursor, tree

A directory browser, a countdown, a count-up timer, the virtual cursor and a tree view

Component by component, the API that the shelf tui uses:

// list: items, delegate, initial width and height
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "Catalogue"
l.SetFilteringEnabled(false) // the program has its own search box
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetItems(items)            // []list.Item
sel, ok := l.SelectedItem().(book)

// table: columns and rows, focus decides whether it reacts to keys
t := table.New(
	table.WithColumns([]table.Column{{Title: "Title", Width: 26}, {Title: "Due", Width: 12}}),
	table.WithRows([]table.Row{{"Dune", "2026-11-02"}}),
	table.WithHeight(6),
)
t.Focus() // t.Blur() stops it reacting to key presses

// textinput: Focus returns a tea.Cmd (it starts the cursor blink)
ti := textinput.New()
ti.Placeholder = "title or author"
cmd := ti.Focus()

// viewport: width and height are options, content is a string
vp := viewport.New(viewport.WithWidth(40), viewport.WithHeight(10))
vp.SoftWrap = true
vp.SetContent(longText)

// spinner and progress
sp := spinner.New(spinner.WithSpinner(spinner.Dot))
pb := progress.New(progress.WithWidth(30))
cmd = pb.SetPercent(0.4) // returns a tea.Cmd that animates to the new value

Differences from v1 that matter when porting or reading older tutorials: viewport width and height are options and methods (SetWidth, SetHeight), and textinput width is a method pair (SetWidth, Width), not fields; textinput prompt styling lives in Styles().Focused.Prompt and Styles().Blurred.Prompt; styles are chosen with an isDark flag (textinput.DefaultStyles(isDark), list.DefaultStyles(isDark)); table.Update does nothing while the table is blurred; progress.SetPercent returns a command because the bar animates. In the v2.2.1 sources the list’s constructor sets its styles with the dark palette hard-coded (the source comment calls it temporary), so on a light terminal pass your own styles built from tea.BackgroundColorMsg.

Source: Bubbles v2 package index and the package pages for list, table, textinput, viewport, spinner, progress and paginator.

A paginator pairs with a long slice that the program renders itself:

p := paginator.New(paginator.WithPerPage(10))
p.SetTotalPages(len(books))
start, end := p.GetSliceBounds(len(books))
visible := books[start:end]
// forward key presses: p, cmd = p.Update(msg)

Lip Gloss: styling and layout

Lip Gloss describes styles as values. lipgloss.NewStyle() returns a Style, and each chained method returns a new style, so a base style can be copied and specialised safely. Render produces the string with ANSI sequences.

var (
	box = lipgloss.NewStyle().
		Border(lipgloss.RoundedBorder()).
		BorderForeground(lipgloss.Color("240"))
	activeBox = box.BorderForeground(lipgloss.Color("63"))
	title     = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4"))
)

left := box.Padding(0, 1).Render(title.Render("Catalogue") + "\n" + body)
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
screen := lipgloss.JoinVertical(lipgloss.Left, header, row, footer)
Area API

Text attributes

Bold, Italic, Underline, UnderlineStyle, Hyperlink, Foreground, Background

Box model

Padding, Margin, Border, BorderStyle, BorderForeground, Width, Height, MaxWidth, MaxHeight, Align

Layout

JoinHorizontal, JoinVertical, Place, PlaceHorizontal, PlaceVertical; lipgloss.Width(s), Height(s) and Size(s) measure rendered text in cells

Colours

lipgloss.Color("21") (ANSI 256), Color("#ff00ff"), the named ANSI constants such as lipgloss.Red, lipgloss.LightDark(isDark)(light, dark) and helpers such as Darken and Blend1D

Compositing

NewLayer, NewCanvas, NewCompositor for layered, cell-based drawing; Compositor.Hit supports mouse hit testing

Sub-packages

table, list and tree render structured output without a running program

What changed in v2: Color is now a function returning image/color.Color (it was a string type); TerminalColor, the Renderer type and AdaptiveColor in the root package are gone; and Render always emits full-fidelity ANSI. Colours are downsampled where the text is written. Inside Bubble Tea v2 that is handled for you; for a plain shelf list that prints styled text, use the Lip Gloss writers:

lipgloss.Println(title.Render("Overdue loans"))             // stdout, downsampled to the terminal's profile
lipgloss.Fprintln(os.Stderr, warn.Render("shelfd offline")) // stderr

To choose between light and dark palettes in a Bubble Tea program, request the terminal’s background colour in Init and build the styles when the answer arrives:

func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.BackgroundColorMsg:
		m.styles = newStyles(msg.IsDark())
	}
	return m, nil
}

func newStyles(isDark bool) styles {
	pick := lipgloss.LightDark(isDark)
	return styles{
		title: lipgloss.NewStyle().Foreground(pick(lipgloss.Color("#333333"), lipgloss.Color("#f1f1f1"))),
	}
}

A compat sub-package (charm.land/lipgloss/v2/compat) offers v1-style adaptive colours for a quick port. Two details of the official documentation matter when measuring layouts. The upgrade guide does not say whether Style.Width and Height count the border, so the shelf tui below sizes the components first and draws the borders around them without setting a width. And wide characters (CJK text, emoji) occupy two cells: use lipgloss.Width rather than len or utf8.RuneCountInString to align columns, as explained on Escape Sequences & Terminal Capabilities.

The complete shelf tui

The program below combines everything so far: a spinner while the catalogue loads from a JSON file in a command, a Bubbles list for the catalogue, a viewport for the details of the selected book, a table of loans, a text input as the search box, mouse-wheel scrolling of the details, Lip Gloss borders around the panes, and the terminal state declared in View. Keys: / focuses the search box (enter or esc leaves it), tab moves between the catalogue and the loans table, pgup and pgdown scroll the details, q or ctrl+c quits.

First the data, in books.json:

[
  {"title": "Dune", "author": "Frank Herbert", "isbn": "9780441013593",
   "notes": "A desert planet, a spice, and a long political game.", "loan": "ana", "due": "2026-11-02"},
  {"title": "The Left Hand of Darkness", "author": "Ursula K. Le Guin", "isbn": "9780441478125",
   "notes": "On Gethen the people have no fixed sex; an envoy has to learn what that means for politics."},
  {"title": "A Canticle for Leibowitz", "author": "Walter M. Miller Jr.", "isbn": "9780060892999",
   "notes": "Monks preserve fragments of a lost technological age.", "loan": "ben", "due": "2026-11-09"},
  {"title": "The Dispossessed", "author": "Ursula K. Le Guin", "isbn": "9780061054884",
   "notes": "Two worlds, one anarchist and one capitalist, and a physicist who belongs to neither."}
]

Then main.go:

package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"unicode"

	"charm.land/bubbles/v2/list"
	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/table"
	"charm.land/bubbles/v2/textinput"
	"charm.land/bubbles/v2/viewport"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
)

// ---- data -----------------------------------------------------------------

type book struct {
	Name   string `json:"title"`
	Author string `json:"author"`
	ISBN   string `json:"isbn"`
	Notes  string `json:"notes"`
	Loan   string `json:"loan,omitempty"`
	Due    string `json:"due,omitempty"`
}

// clean drops control characters, so a hostile title cannot inject escape sequences.
func clean(s string) string {
	return strings.Map(func(r rune) rune {
		if unicode.IsControl(r) && r != '\n' {
			return -1
		}
		return r
	}, s)
}

// book implements list.DefaultItem.
func (b book) Title() string       { return clean(b.Name) }
func (b book) Description() string { return clean(b.Author) + " - " + b.ISBN }
func (b book) FilterValue() string { return b.Name + " " + b.Author }

// ---- messages and commands --------------------------------------------------

type loadedMsg []book
type failedMsg struct{ err error }

func loadBooks(path string) tea.Cmd {
	return func() tea.Msg {
		data, err := os.ReadFile(path)
		if err != nil {
			return failedMsg{err}
		}
		var books []book
		if err := json.Unmarshal(data, &books); err != nil {
			return failedMsg{err}
		}
		return loadedMsg(books)
	}
}

// ---- model ------------------------------------------------------------------

type pane int

const (
	paneCatalogue pane = iota
	paneSearch
	paneLoans
)

type model struct {
	path    string
	all     []book
	list    list.Model
	search  textinput.Model
	loans   table.Model
	detail  viewport.Model
	spin    spinner.Model
	focus   pane
	loading bool
	err     error

	width, height int
}

var (
	box       = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240"))
	activeBox = box.BorderForeground(lipgloss.Color("63"))
	helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)

func newModel(path string) model {
	l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
	l.Title = "Catalogue"
	l.SetFilteringEnabled(false)
	l.SetShowHelp(false)
	l.SetShowStatusBar(false)

	ti := textinput.New()
	ti.Prompt = "/ "
	ti.Placeholder = "search title or author"

	cols := []table.Column{{Title: "Title", Width: 26}, {Title: "Borrower", Width: 14}, {Title: "Due", Width: 12}}
	t := table.New(table.WithColumns(cols), table.WithHeight(6))

	vp := viewport.New(viewport.WithWidth(30), viewport.WithHeight(10))
	vp.SoftWrap = true

	return model{
		path:    path,
		list:    l,
		search:  ti,
		loans:   t,
		detail:  vp,
		spin:    spinner.New(spinner.WithSpinner(spinner.Dot)),
		loading: true,
	}
}

func (m model) Init() tea.Cmd {
	return tea.Batch(m.spin.Tick, loadBooks(m.path))
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		m.layout()
		return m, nil

	case spinner.TickMsg:
		if !m.loading {
			return m, nil // stop the animation by not returning another tick
		}
		var cmd tea.Cmd
		m.spin, cmd = m.spin.Update(msg)
		return m, cmd

	case loadedMsg:
		m.loading = false
		m.all = []book(msg)
		m.applyFilter()
		return m, nil

	case failedMsg:
		m.loading = false
		m.err = msg.err
		return m, nil

	case tea.MouseWheelMsg:
		var cmd tea.Cmd
		m.detail, cmd = m.detail.Update(msg)
		return m, cmd

	case tea.KeyPressMsg:
		return m.handleKey(msg)
	}
	return m, nil
}

func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.String()
	if key == "ctrl+c" {
		return m, tea.Quit
	}

	// While the search box has focus, it gets every key except the two that leave it.
	if m.focus == paneSearch {
		if key == "enter" || key == "esc" {
			m.focus = paneCatalogue
			m.search.Blur()
			return m, nil
		}
		var cmd tea.Cmd
		m.search, cmd = m.search.Update(msg)
		m.applyFilter()
		return m, cmd
	}

	switch key {
	case "q":
		return m, tea.Quit
	case "/":
		m.focus = paneSearch
		return m, m.search.Focus()
	case "tab":
		if m.focus == paneCatalogue {
			m.focus = paneLoans
			m.loans.Focus()
		} else {
			m.focus = paneCatalogue
			m.loans.Blur()
		}
		return m, nil
	case "pgdown":
		m.detail.ScrollDown(5)
		return m, nil
	case "pgup":
		m.detail.ScrollUp(5)
		return m, nil
	}

	var cmd tea.Cmd
	if m.focus == paneLoans {
		m.loans, cmd = m.loans.Update(msg)
	} else {
		m.list, cmd = m.list.Update(msg)
		m.showSelected()
	}
	return m, cmd
}

// applyFilter rebuilds the list and the loans table from m.all and the search text.
func (m *model) applyFilter() {
	q := strings.ToLower(strings.TrimSpace(m.search.Value()))
	var items []list.Item
	var rows []table.Row
	for _, b := range m.all {
		if q == "" || strings.Contains(strings.ToLower(b.FilterValue()), q) {
			items = append(items, b)
		}
		if b.Loan != "" {
			rows = append(rows, table.Row{clean(b.Name), clean(b.Loan), b.Due})
		}
	}
	m.list.SetItems(items)
	m.loans.SetRows(rows)
	m.showSelected()
}

func (m *model) showSelected() {
	b, ok := m.list.SelectedItem().(book)
	if !ok {
		m.detail.SetContent("No book selected.")
		return
	}
	var sb strings.Builder
	fmt.Fprintf(&sb, "%s\n%s\nISBN %s\n\n%s\n", clean(b.Name), clean(b.Author), b.ISBN, clean(b.Notes))
	if b.Loan != "" {
		fmt.Fprintf(&sb, "\nOn loan to %s until %s\n", clean(b.Loan), b.Due)
	}
	m.detail.SetContent(sb.String())
	m.detail.GotoTop()
}

// layout sizes the components. The panes get a one-cell border on each side.
func (m *model) layout() {
	if m.width < 40 || m.height < 16 {
		return
	}
	leftW := m.width / 2
	rightW := m.width - leftW
	midH := m.height - 10 // search line + loans pane (8) + help line

	m.list.SetSize(leftW-2, midH-2)
	m.detail.SetWidth(rightW - 4) // border (2) and padding (2)
	m.detail.SetHeight(midH - 2)
	m.loans.SetWidth(m.width - 2)
	m.loans.SetHeight(6)
	m.search.SetWidth(m.width - 4)
}

func (m model) View() tea.View {
	var body string
	switch {
	case m.err != nil:
		body = "error: " + clean(m.err.Error()) + "\n"
	case m.loading:
		body = m.spin.View() + " loading " + m.path + "\n"
	case m.width < 40 || m.height < 16:
		body = "terminal too small (needs 40x16)\n"
	default:
		body = m.render()
	}
	v := tea.NewView(body)
	v.AltScreen = true
	v.MouseMode = tea.MouseModeCellMotion
	v.WindowTitle = "shelf tui"
	return v
}

func (m model) render() string {
	cat, loans := box, box
	switch m.focus {
	case paneCatalogue:
		cat = activeBox
	case paneLoans:
		loans = activeBox
	}
	left := cat.Render(m.list.View())
	right := box.Padding(0, 1).Render(m.detail.View())
	middle := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
	bottom := loans.Render(m.loans.View())
	help := helpStyle.Render("/ search   tab loans   pgup/pgdn scroll   q quit")
	return lipgloss.JoinVertical(lipgloss.Left, m.search.View(), middle, bottom, help)
}

func main() {
	path := "books.json"
	if len(os.Args) > 1 {
		path = os.Args[1]
	}
	if _, err := tea.NewProgram(newModel(path)).Run(); err != nil {
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
}

Run it with go run . books.json. Points worth noting in the program:

  • One owner of state. Every change to m happens in Update (and the helpers it calls). The load happens in a command and reaches the model as loadedMsg.

  • Sub-models are values. m.list, cmd = m.list.Update(msg) stores the new component; forgetting the assignment silently discards its state, and forgetting to return cmd drops its blink, tick or animation messages.

  • The spinner stops by omission. A spinner tick keeps the animation alive only if Update returns the command that spinner.Update produces; once loading is false the program stops returning it.

  • Focus is explicit. textinput and table both take keys only while focused, so the program decides who receives each key (the focus field) instead of broadcasting every message.

  • Untrusted text is cleaned. Book titles and notes come from a file; clean removes control characters so that a record cannot move the cursor or set a window title through the terminal. The wider rules are on Security.

  • Layout is arithmetic on WindowSizeMsg. Nothing is drawn at the real size until the first size message arrives, which is why View shows a loading line or a "too small" line first.

  • The list keeps its own quit bindings (q and esc in its key map); the program intercepts q before forwarding keys, and esc in the catalogue pane is handled by the list.

Huh: forms and prompts

Huh builds forms from groups (pages) of fields: Input, Text, Select[T], MultiSelect[T], Confirm, FilePicker and Note. A form can run on its own, which is the right tool for shelf add when it is started interactively, or be embedded in a Bubble Tea program because huh.Form implements tea.Model.

package main

import (
	"errors"
	"fmt"
	"os"
	"strings"

	"charm.land/huh/v2"
)

func validISBN(s string) error {
	digits := strings.NewReplacer("-", "", " ", "").Replace(s)
	if len(digits) != 10 && len(digits) != 13 {
		return errors.New("an ISBN has 10 or 13 digits")
	}
	return nil
}

func main() {
	var title, author, isbn, section string
	var confirm bool

	form := huh.NewForm(
		huh.NewGroup(
			huh.NewInput().Title("Title").Value(&title).
				Validate(func(s string) error {
					if strings.TrimSpace(s) == "" {
						return errors.New("the title is required")
					}
					return nil
				}),
			huh.NewInput().Title("Author").Value(&author),
			huh.NewInput().Title("ISBN").Value(&isbn).Validate(validISBN),
		),
		huh.NewGroup(
			huh.NewSelect[string]().
				Title("Section").
				Options(huh.NewOptions("fiction", "science", "history")...).
				Value(&section),
			huh.NewConfirm().Title("Add this book?").Value(&confirm),
		),
	).WithAccessible(os.Getenv("ACCESSIBLE") != "")

	if err := form.Run(); err != nil {
		if errors.Is(err, huh.ErrUserAborted) {
			fmt.Fprintln(os.Stderr, "cancelled")
			os.Exit(130) // the shell convention for "interrupted"
		}
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
	if confirm {
		fmt.Printf("added %q by %s (%s) to %s\n", title, author, isbn, section)
	}
}

The details the documentation gives: fields are configured with chained methods (Title, Value(&x), Validate, Options); results can also be read with form.GetString(key), GetInt and GetBool for fields that set a Key; the sentinel errors are huh.ErrUserAborted, huh.ErrTimeout and huh.ErrTimeoutUnsupported; RunWithContext(ctx) and WithTimeout bound the wait; five themes are provided (Charm, Dracula, Catppuccin, Base 16 and the base theme) and the theme functions take an isDark argument in v2. Dynamic fields use the *Func variants (TitleFunc, OptionsFunc) with a binding so they are recomputed only when that variable changes.

Accessible mode. form.WithAccessible(true) replaces the TUI by standard prompts that work better with screen readers and dictation software. The README recommends driving it from an environment variable or a flag, as the program above does; the broader accessibility rules for terminal programs are on Accessibility & Internationalization, and other prompt libraries are compared on Interactive Prompts & Line Editing.

Embedding. The README embeds a form by storing the *huh.Form in the parent model, calling its Init and Update, and checking form.State == huh.StateCompleted before reading values. Its sample renders with a View() string method that was written against the v1 tea.Model; in a v2 program the parent’s View must return tea.View, so wrap the form’s view string with tea.NewView, and check the form’s own View signature in the version you depend on.

Glamour: Markdown in the terminal

Glamour renders Markdown with an ANSI style sheet. The book notes in the Bookshelf are Markdown, so shelf show can print them formatted:

package main

import (
	"fmt"
	"os"

	"charm.land/glamour/v2"
	"charm.land/lipgloss/v2"
)

func main() {
	notes := "# Dune\n\nA desert planet, a spice, and a long political game.\n\n- **Author:** Frank Herbert\n- **Shelf:** fiction\n"

	out, err := glamour.Render(notes, "dark")
	if err != nil {
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
	lipgloss.Print(out) // Glamour does not downsample colours; Lip Gloss does

	r, err := glamour.NewTermRenderer(glamour.WithWordWrap(60))
	if err == nil {
		if wrapped, err := r.Render(notes); err == nil {
			lipgloss.Print(wrapped)
		}
	}
}

glamour.Render(markdown, style) takes a built-in style name (the README shows dark) or a style file path; RenderWithEnvironmentConfig and WithEnvironmentConfig() read the style from the GLAMOUR_STYLE environment variable. NewTermRenderer takes options: WithStandardStyle, WithStylePath, WithStyles, WithWordWrap (default 80), WithTableWrap, WithInlineTableLinks, WithPreservedNewLines, WithEmoji, WithChromaFormatter, WithBaseURL. The renderer does not look at the terminal, so it applies no colour downsampling; the README’s advice is to print its output with a Lip Gloss writer. The v2 package documentation does not describe an automatic light/dark style: choose the style from tea.BackgroundColorMsg.IsDark() or GLAMOUR_STYLE yourself. Inside a Bubble Tea program the usual pattern is to render the Markdown to a string in a command and show it in a viewport, as the pager example does.

Testing Bubble Tea programs

Most of a Bubble Tea program can be tested without a terminal because Update is a function: build a message, call Update, assert on the returned model. This test for the shelf tui types text into the search box and checks the list:

package main

import (
	"testing"

	tea "charm.land/bubbletea/v2"
)

func key(s string) tea.KeyPressMsg {
	r := []rune(s)[0]
	return tea.KeyPressMsg{Code: r, Text: s}
}

func TestSearchFiltersCatalogue(t *testing.T) {
	var m tea.Model = newModel("books.json")
	m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
	m, _ = m.Update(loadedMsg{
		{Name: "Dune", Author: "Frank Herbert", ISBN: "9780441013593"},
		{Name: "The Dispossessed", Author: "Ursula K. Le Guin", ISBN: "9780061054884"},
	})
	if n := len(m.(model).list.Items()); n != 2 {
		t.Fatalf("expected 2 books, got %d", n)
	}

	for _, s := range []string{"/", "d", "u", "n", "e"} {
		m, _ = m.Update(key(s))
	}
	if n := len(m.(model).list.Items()); n != 1 {
		t.Fatalf("expected 1 book after searching for dune, got %d", n)
	}
}

Command messages are tested the same way: call the command function and inspect the message it returns (msg := loadBooks("testdata/books.json")()).

teatest runs a whole program against a fake terminal. Its documented API: teatest.NewTestModel(t, model, opts...) with WithInitialTermSize(w, h) and WithProgramOptions(...); tm.Send(msg) and tm.Type(text); tm.Output() and tm.FinalOutput(t); tm.FinalModel(t); tm.WaitFinished(t, opts...); teatest.WaitFor(t, reader, condition, opts...) with WithDuration and WithCheckInterval (defaults 1 s and 50 ms); and teatest.RequireEqualOutput(t, out) for golden files, regenerated with the -update flag (it needs the system diff tool to show differences).

import (
	"bytes"
	"testing"
	"time"

	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/x/exp/teatest/v2"
)

func TestProgramQuits(t *testing.T) {
	tm := teatest.NewTestModel(t, newModel("testdata/books.json"), teatest.WithInitialTermSize(100, 30))

	teatest.WaitFor(t, tm.Output(), func(out []byte) bool {
		return bytes.Contains(out, []byte("Dune"))
	}, teatest.WithDuration(3*time.Second))

	tm.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
	tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
}

Two honest caveats. The package lives under x/exp, is explicitly experimental and has no tagged version, so its API can change with a plain go get -u. And the example test in the Bubble Tea repository (examples/simple/main_test.go) is commented out; its comments say it fails because the output now contains colour escape sequences when run against a buffer rather than a terminal, and suggest passing tea.WithColorProfile(Ascii), and that an interactive variant is flaky because the initial terminal size is not set reliably. Prefer Update-level tests for logic, use teatest for smoke tests with generous timeouts, and run the program under a real pseudo-terminal in CI for the rest, as described in Testing Terminal Apps & TUIs.

VHS: demos and visual checks

VHS scripts a terminal session in a tape file and records it. It needs ttyd and ffmpeg on the PATH (the official Docker image ghcr.io/charmbracelet/vhs bundles them) and is available through Homebrew, pacman, Nix, Scoop and winget, or as a release binary or go install. A tape for the shelf tui:

Output shelf-tui.gif
Output shelf-tui.txt

Require go

Set Shell "bash"
Set FontSize 18
Set Width 1200
Set Height 700

Type "go run . books.json"
Enter
Sleep 2s

Type "/"
Type "dune"
Sleep 1s
Enter

Down
Sleep 500ms
Tab
Sleep 500ms
Type "q"
Sleep 500ms

The tape language has setup commands (Output, Require, Set, Env, Source), settings (Shell, FontSize, Width, Height, Theme, TypingSpeed and more), input commands (Type, Enter, Tab, Space, the arrow keys, PageUp, PageDown, Ctrl+<key>), timing and capture commands (Sleep, Wait, Hide, Show, Screenshot) and Copy and Paste. The output formats are GIF, MP4 and WebM, a directory of PNG frames, and .txt / .ascii text; the README presents the text output as golden files for integration tests, which turns a tape into a regression test of the rendered screen. Other commands are vhs new, vhs record (writes a tape from your own session), vhs publish, vhs themes, vhs manual and vhs serve (an SSH server). The charmbracelet/vhs-action GitHub Action regenerates GIFs in CI.

Pitfalls and security

  • Mixing generations. A v1 import (github.com/charmbracelet/bubbletea) next to v2 packages compiles into two unrelated type systems. Search the whole module for the old paths when upgrading, and use the upgrade guide as the checklist.

  • Printing to stdout. Any fmt.Print while the program runs corrupts the frame. Log to a file, use tea.Println in inline programs, or send a message.

  • Work in Update. A blocking call in Update or View freezes the interface. Do I/O in a command and return a message.

  • Lost commands. Dropping the command returned by a sub-model (cmd from list.Update, textinput.Focus(), a spinner Tick) stops its animation or blink without any error.

  • Size before first message. WindowSizeMsg is the source of truth for layout; components created with a size of zero must be resized when it arrives. Terminals that are too small need a fallback view.

  • Stale snippets. Many tutorials online use v1: View() string, tea.KeyMsg with msg.Type, tea.WithAltScreen(), tea.EnterAltScreen. They will not compile against v2.

  • Escape-sequence injection. Anything not typed by the user (titles, notes, loan names, server messages) must be stripped of control characters before it reaches View or Glamour, or it can reposition the cursor, change the window title or fake UI. See Security.

  • Secrets in the TUI. A password field needs an echo mode (textinput has one) and must not be written to the debug log file; remember that a log opened with tea.LogToFile is an ordinary file with the process’s umask.

  • Dark-only defaults. Some Bubbles defaults use dark palettes. Detect the background with tea.RequestBackgroundColor and build styles from msg.IsDark().

  • Terminal left in raw mode. A hard kill (SIGKILL) cannot be handled; the user needs reset. Make the TUI quit on SIGTERM by cancelling the context passed with WithContext.

  • A TUI is not an API. Keep the data operations behind plain subcommands and structured output so scripts and agents never have to drive the interface.

What the official documentation does not provide

  • There is no platform support matrix and no statement of the minimum Windows console host for Bubble Tea; the platform section above is limited to facts in the source tree.

  • Bubble Tea’s package documentation lists ProgramStatus without describing it, and several type declarations are only visible in the source; they are not relied on here beyond what is quoted.

  • Huh’s README embeds a form with a v1-style View() string; no v2 embedding sample is published, so verify the View signature against your Huh version.

  • Glamour v2 documents named styles and GLAMOUR_STYLE but no automatic light/dark selection.

  • teatest has no tagged release and the repository’s own example test is disabled; there is no official guide to testing Bubble Tea v2 programs.

  • None of the examples on this page were compiled or run; verify them with go vet and go test against the module versions in your go.sum.

Version notes

Component Note

Bubble Tea v2.1.0

Published 2026-10-08; the v2 line reached general availability on 2026-02-24. The module declares go 1.26.0. UPGRADE_GUIDE_V2.md in the repository is the migration reference from v1

Bubbles v2.2.1

Published 2026-08-24; list.New takes (items, delegate, width, height); viewport and textinput sizes are methods and options

Lip Gloss v2.0.6

Published 2026-08-11; Color is a function returning color.Color; rendering emits full-fidelity ANSI and downsampling happens at print time

Huh v2.0.3

Published 2026-03-10; theme functions take isDark; Input.Password is deprecated in favour of EchoMode

Glamour v2.0.1

Published 2026-06-12; declares go 1.26.7

teatest

github.com/charmbracelet/x/exp/teatest/v2, experimental and untagged

VHS v0.12.1

Declares go 1.26.7