bubbletea-docs
Bubble Tea TUI Framework Skill
Overview
Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods: Init() (initial command), Update() (handle messages), View() (render UI as string).
This skill covers the complete Charm ecosystem:
- Bubble Tea - Core TUI framework
- Bubbles - Reusable UI components
- Lip Gloss - Terminal styling
- Huh - Interactive forms
Installation
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles # UI components
go get github.com/charmbracelet/lipgloss # Styling
go get github.com/charmbracelet/huh # Forms
Quick Start
package main
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
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.choices)-1 { m.cursor++ }
case "enter", " ":
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() string {
s := "Choose:\n\n"
for i, choice := range m.choices {
cursor, checked := " ", " "
if m.cursor == i { cursor = ">" }
if _, ok := m.selected[i]; ok { checked = "x" }
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
return s + "\nq to quit.\n"
}
func main() {
p := tea.NewProgram(model{
choices: []string{"Option A", "Option B", "Option C"},
selected: make(map[int]struct{}),
})
if _, err := p.Run(); err != nil { log.Fatal(err) }
}
Core Concepts
Messages and Commands
// Custom messages
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }
// Command returns a message (runs async)
func fetchData() tea.Msg {
resp, err := http.Get("https://api.example.com")
if err != nil { return errMsg{err} }
defer resp.Body.Close()
// ... process response
return dataMsg{data: "result"}
}
// Handle in Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dataMsg:
m.data = msg.data
case errMsg:
m.err = msg.error
}
return m, nil
}
// Start command in Init
func (m model) Init() tea.Cmd {
return fetchData // Runs async, sends message when done
}
Batch and Sequence Commands
// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)
// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)
Window Size Handling
type model struct {
width, height int
viewport viewport.Model
ready bool
}
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
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
}
}
return m, nil
}
Common Patterns
Program Options
// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())
// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())
// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
Key Bindings with bubbles/key
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
// In Update
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Up):
m.cursor--
case key.Matches(msg, keys.Quit):
return m, tea.Quit
}
Multiple Views
type model struct {
state int // 0=menu, 1=detail
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state {
case 0: return m.updateMenu(msg)
case 1: return m.updateDetail(msg)
}
return m, nil
}
func (m model) View() string {
switch m.state {
case 0: return m.menuView()
case 1: return m.detailView()
}
return ""
}
Bubbles Components
Component Quick Reference
| Component | Import | Init | Key Method |
|---|---|---|---|
| Spinner | bubbles/spinner |
spinner.New() |
.Tick in Init |
| TextInput | bubbles/textinput |
textinput.New() |
.Focus(), .Value() |
| TextArea | bubbles/textarea |
textarea.New() |
.Focus(), .Value() |
| List | bubbles/list |
list.New(items, delegate, w, h) |
.SelectedItem() |
| Table | bubbles/table |
table.New(opts...) |
.SelectedRow() |
| Viewport | bubbles/viewport |
viewport.New(w, h) |
.SetContent(), .ScrollUp(), .ScrollDown() |
| Progress | bubbles/progress |
progress.New() |
.SetPercent() |
| Help | bubbles/help |
help.New() |
.View(keyMap) |
| FilePicker | bubbles/filepicker |
filepicker.New() |
.DidSelectFile() |
| Timer | bubbles/timer |
timer.New(duration) |
.Toggle() |
| Stopwatch | bubbles/stopwatch |
stopwatch.New() |
.Elapsed() |
Focus Management (Multiple Inputs)
type model struct {
inputs []textinput.Model
focusIndex int
}
func (m *model) nextInput() tea.Cmd {
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
return m.inputs[m.focusIndex].Focus()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
return m, m.nextInput()
}
}
// Update all inputs
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return m, tea.Batch(cmds...)
}
Component Composition
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// Update all components and collect commands
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
Spinner Example
s := spinner.New()
s.Spinner = spinner.Dot // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init(): return m.spinner.Tick
// In Update(): handle spinner.TickMsg
List with Custom Items
type item struct{ title, desc string }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title } // Required for filtering
items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"
Table
columns := []table.Column{
{Title: "ID", Width: 10},
{Title: "Name", Width: 20},
}
rows := []table.Row{
{"1", "Alice"},
{"2", "Bob"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)
Lip Gloss Styling
Basic Styling
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("#7D56F4")).
Border(lipgloss.RoundedBorder()).
Padding(1, 2)
output := style.Render("Hello, World!")
Colors
// Hex colors
lipgloss.Color("#FF00FF")
// ANSI 256 colors
lipgloss.Color("205")
// Adaptive colors (auto light/dark background)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}
// Complete color profiles
lipgloss.CompleteColor{
TrueColor: "#FF00FF",
ANSI256: "205",
ANSI: "5",
}
Layout Composition
// Horizontal layout
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Vertical layout
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// Center content in box
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)
Frame Size for Calculations
// Account for padding/border/margin in calculations
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)
// Dynamic content height
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerH
Border Styles
lipgloss.NormalBorder() // Standard box
lipgloss.RoundedBorder() // Rounded corners
lipgloss.ThickBorder() // Thick lines
lipgloss.DoubleBorder() // Double lines
lipgloss.HiddenBorder() // Invisible (for spacing)
Huh Forms
Quick Start
import "github.com/charmbracelet/huh"
var name string
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name),
huh.NewConfirm().
Title("Ready to proceed?").
Value(&confirmed),
),
)
err := form.Run()
if err != nil {
if err == huh.ErrUserAborted {
fmt.Println("Cancelled")
return
}
log.Fatal(err)
}
Field Types (GENERIC TYPES REQUIRED)
// Input - single line text
huh.NewInput().Title("Name").Value(&name)
// Text - multi-line
huh.NewText().Title("Bio").Lines(5).Value(&bio)
// Select - MUST specify type
huh.NewSelect[string]().
Title("Choose").
Options(
huh.NewOption("Option A", "a"),
huh.NewOption("Option B", "b"),
).
Value(&choice)
// MultiSelect - MUST specify type
huh.NewMultiSelect[string]().
Title("Choose many").
Options(huh.NewOptions("A", "B", "C")...).
Limit(2).
Value(&choices)
// Confirm
huh.NewConfirm().
Title("Sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)
// FilePicker
huh.NewFilePicker().
Title("Select file").
AllowedTypes([]string{".go", ".md"}).
Value(&filepath)
Validation
huh.NewInput().
Title("Email").
Value(&email).
Validate(func(s string) error {
if !strings.Contains(s, "@") {
return errors.New("invalid email")
}
return nil
})
Dynamic Forms (OptionsFunc/TitleFunc)
var country string
var state string
huh.NewSelect[string]().
Value(&state).
TitleFunc(func() string {
if country == "Canada" { return "Province" }
return "State"
}, &country). // Recompute when country changes
OptionsFunc(func() []huh.Option[string] {
return huh.NewOptions(getStatesFor(country)...)
}, &country)
Bubble Tea Integration
type Model struct {
form *huh.Form
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
// Form completed - access values
name := m.form.GetString("name")
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!"
}
return m.form.View()
}
Themes
form.WithTheme(huh.ThemeCharm()) // Default pink/purple
form.WithTheme(huh.ThemeDracula()) // Dark purple/pink
form.WithTheme(huh.ThemeCatppuccin()) // Pastel
form.WithTheme(huh.ThemeBase16()) // Muted
form.WithTheme(huh.ThemeBase()) // Minimal
Spinner for Loading
import "github.com/charmbracelet/huh/spinner"
err := spinner.New().
Title("Processing...").
Action(func() {
time.Sleep(2 * time.Second)
processData()
}).
Run()
Common Gotchas
Bubble Tea Core
-
Blocking in Update/View: Never block. Use commands for I/O:
// BAD time.Sleep(time.Second) // Blocks event loop! // GOOD return m, func() tea.Msg { time.Sleep(time.Second) return doneMsg{} } -
Goroutines modifying model: Race condition! Use commands instead:
// BAD go func() { m.data = fetch() }() // Race! // GOOD return m, func() tea.Msg { return dataMsg{fetch()} } -
Viewport before WindowSizeMsg: Initialize after receiving dimensions:
if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height) m.ready = true } -
Using receiver methods incorrectly: Only use
func (m *model)for internal helpers. Model interface methods must use value receiversfunc (m model). -
Startup commands via Init: Don't use
tea.EnterAltScreenin Init. Usetea.WithAltScreen()option instead. -
Messages not in order:
tea.Batchresults arrive in ANY order. Usetea.Sequencewhen order matters.
Bubbles Components
-
Spinner not animating: Must return
m.spinner.TickfromInit()and handlespinner.TickMsgin Update. -
TextInput not accepting input: Must call
.Focus()before input accepts keystrokes. -
Component updates not reflected: Always reassign after Update:
m.spinner, cmd = m.spinner.Update(msg) // Must reassign! -
Multiple components losing commands: Use
tea.Batch(cmds...)to combine all commands. -
List items not filtering: Must implement
FilterValue() stringon items. -
Table not responding: Must set
table.WithFocused(true)or callt.Focus().
Lip Gloss
-
Style method has no effect: Styles are immutable, must reassign:
// BAD style.Bold(true) // Result discarded! // GOOD style = style.Bold(true) -
Alignment not working: Requires explicit Width:
style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center) -
Width calculation wrong: Use
lipgloss.Width()notlen()for unicode. -
Layout arithmetic errors: Use
style.GetFrameSize()to account for padding/border/margin.
Huh Forms
-
Generic types required:
SelectandMultiSelectMUST have type parameter:// BAD - won't compile huh.NewSelect() // GOOD huh.NewSelect[string]() -
Value takes pointer: Always pass pointer:
.Value(&myVar)not.Value(myVar). -
OptionsFunc not updating: Must pass binding variable:
.OptionsFunc(fn, &country) // Recomputes when country changes -
Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:
// BAD huh.NewInput().Placeholder("example@email.com") // GOOD huh.NewInput().Description("e.g. example@email.com") -
Loop variable closure capture: Capture explicitly in loops:
for _, name := range items { currentName := name // Capture! huh.NewInput().Value(&configs[currentName].Field) } -
Don't intercept Enter before form: Let huh handle Enter for navigation.
Debugging
// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "app")
defer f.Close()
}
log.Println("Debug message")
Best Practices
- Keep Update/View fast - The event loop blocks on these
- Use tea.Cmd for all I/O - HTTP, file, database operations
- Use tea.Batch for parallel - Multiple independent commands
- Use tea.Sequence for ordered - Commands that must run in order
- Store window dimensions - Handle tea.WindowSizeMsg, update components
- Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
- Use value receivers -
func (m model) Updatenotfunc (m *model) Update - Define styles as package variables - Reuse instead of creating in loops
- Use AdaptiveColor - For light/dark terminal support
- Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms
Additional Resources
- references/API.md - Complete API reference
- references/EXAMPLES.md - Extended code examples
- references/TROUBLESHOOTING.md - Common errors and solutions
Essential Imports
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table" // Static tables
"github.com/charmbracelet/lipgloss/tree" // Hierarchical data
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/table" // Interactive tables
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/timer"
"github.com/charmbracelet/bubbles/stopwatch"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/wish" // SSH TUI server
"github.com/lrstanley/bubblezone" // Mouse zones
)
Lip Gloss Tree (Hierarchical Data)
Render tree structures with customizable enumerators:
import "github.com/charmbracelet/lipgloss/tree"
t := tree.Root("Root").
Child("Child 1").
Child(
tree.Root("Child 2").
Child("Grandchild 1").
Child("Grandchild 2"),
).
Child("Child 3")
// Custom enumerator
t.Enumerator(tree.RoundedEnumerator) // ├── └──
t.Enumerator(tree.BulletEnumerator) // • bullets
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.
// Custom styling
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))
fmt.Println(t)
Lip Gloss Table (Static Rendering)
For high-performance static tables (reports, logs). Use bubbles/table for interactive selection.
import "github.com/charmbracelet/lipgloss/table"
t := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
Headers("NAME", "AGE", "CITY").
Row("Alice", "30", "NYC").
Row("Bob", "25", "LA").
Wrap(true). // Enable text wrapping
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle()
})
fmt.Println(t)
When to use which table:
lipgloss/table: Static rendering, reports, logs, non-interactivebubbles/table: Interactive selection, keyboard navigation, focused rows
Custom Component Pattern (Sub-Model)
Create reusable Bubble Tea components by exposing Init, Update, View:
// counter.go - Reusable component
package counter
import tea "github.com/charmbracelet/bubbletea"
type Model struct {
Count int
Min int
Max int
}
func New(min, max int) Model {
return Model{Min: min, Max: max}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "+", "=":
if m.Count < m.Max { m.Count++ }
case "-":
if m.Count > m.Min { m.Count-- }
}
}
return m, nil
}
func (m Model) View() string {
return fmt.Sprintf("Count: %d", m.Count)
}
// parent.go - Using the component
type parentModel struct {
counter counter.Model
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.counter, cmd = m.counter.Update(msg)
return m, cmd
}
ProgramContext Pattern (Production)
Share global state across components without prop drilling:
// context.go
type ProgramContext struct {
Config *Config
Theme *Theme
Width int
Height int
StartTask func(Task) tea.Cmd // Callback for background tasks
}
// model.go
type Model struct {
ctx *ProgramContext
sidebar sidebar.Model
main main.Model
tasks map[string]Task
spinner spinner.Model
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
// Sync all components
m.sidebar.SetHeight(msg.Height)
m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
}
return m, nil
}
// Initialize context with task callback
func NewModel(cfg *Config) Model {
ctx := &ProgramContext{Config: cfg}
m := Model{ctx: ctx, tasks: make(map[string]Task)}
ctx.StartTask = func(t Task) tea.Cmd {
m.tasks[t.ID] = t
return m.spinner.Tick
}
return m
}
Testing with teatest
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestModel(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Wait for specific output
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return strings.Contains(string(bts), "Selected")
}, teatest.WithDuration(time.Second))
// Check final state
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
final := tm.FinalModel(t).(Model)
if final.selected != "expected" {
t.Errorf("expected 'expected', got %q", final.selected)
}
}
SSH Integration with Wish
Serve TUI apps over SSH:
import (
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/ssh"
)
func main() {
s, err := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
),
)
if err != nil { log.Fatal(err) }
log.Println("Starting SSH server on :2222")
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
m := NewModel(pty.Window.Width, pty.Window.Height)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
Mouse Zones with bubblezone
Define clickable regions without coordinate math:
import zone "github.com/lrstanley/bubblezone"
type model struct {
zone *zone.Manager
}
func newModel() model {
return model{zone: zone.New()}
}
func (m model) View() string {
// Wrap clickable elements
button1 := m.zone.Mark("btn1", "[Button 1]")
button2 := m.zone.Mark("btn2", "[Button 2]")
return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
// Check which zone was clicked
if m.zone.Get("btn1").InBounds(msg) {
// Button 1 clicked
}
if m.zone.Get("btn2").InBounds(msg) {
// Button 2 clicked
}
}
return m, nil
}
// Wrap program with zone.NewGlobal() for simpler API
func main() {
zone.NewGlobal()
p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
p.Run()
}