Bubble Tea

TUITerminalElm-ArchitectureFunctionalGo

GitHub Overview

charmbracelet/bubbletea

A powerful little TUI framework 🏗

Stars33,527
Watchers123
Forks945
Created:January 10, 2020
Language:Go
License:MIT License

Topics

clielm-architectureframeworkfunctionalgogolanghacktoberfesttui

Star History

charmbracelet/bubbletea Star History
Data as of: 7/25/2025, 11:09 AM

Bubble Tea

Bubble Tea is a modern TUI framework based on The Elm Architecture. It leverages functional programming concepts to express state management and UI updates declaratively. As the core of the Charmbracelet ecosystem, it has become the de facto standard for TUI development in the Go language.

Features

The Elm Architecture

  • Model: Represents the application state
  • View: Renders UI based on the Model
  • Update: Updates Model in response to messages
  • Message-driven: Clear separation of events and state changes

Performance and Features

  • Frame-rate based: Smooth animations and rendering
  • Mouse support: Click, wheel, and motion events
  • Focus management: Focus event tracking
  • Async I/O: Leverages Go's concurrency
  • Production-ready: Adopted by numerous production applications

Developer Experience

  • Simple API: Intuitive and easy to learn
  • Testability: Easy testing with pure functions and immutability
  • Debug features: Built-in debug mode
  • Extensibility: Custom renderers and middleware

Charmbracelet Ecosystem

  • Lip Gloss: Styling library
  • Bubbles: Reusable component collection
  • Glamour: Markdown rendering
  • Charm: Backend services

Basic Usage

Installation

go get github.com/charmbracelet/bubbletea

Hello World

package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
)

// Define Model
type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}

// Initialize function
func initialModel() model {
    return model{
        choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
        selected: make(map[int]struct{}),
    }
}

// Init command
func (m model) Init() tea.Cmd {
    return nil
}

// Update function
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", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    
    return m, nil
}

// View function
func (m model) View() string {
    s := "What should we buy at the market?\n\n"

    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }

        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }

        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }

    s += "\nPress q to quit.\n"
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        fmt.Printf("Alas, there's been an error: %v\n", err)
        os.Exit(1)
    }
}

Commands and Messages

package main

import (
    "time"
    tea "github.com/charmbracelet/bubbletea"
)

// Custom message
type tickMsg time.Time

// Command definition
func tickCmd() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return tickMsg(t)
    })
}

type model struct {
    lastTick time.Time
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" || msg.String() == "ctrl+c" {
            return m, tea.Quit
        }
        
    case tickMsg:
        m.lastTick = time.Time(msg)
        return m, tickCmd()
    }
    
    return m, nil
}

func (m model) View() string {
    return "The time is " + m.lastTick.Format("15:04:05")
}

Styling with Lip Gloss

package main

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

var (
    // Style definitions
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("#7D56F4")).
        PaddingTop(1).
        PaddingBottom(1)
        
    itemStyle = lipgloss.NewStyle().
        PaddingLeft(4)
        
    selectedItemStyle = lipgloss.NewStyle().
        PaddingLeft(2).
        Foreground(lipgloss.Color("170"))
        
    helpStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("241")).
        PaddingTop(1)
)

type model struct {
    spinner  spinner.Model
    loading  bool
    items    []string
    cursor   int
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    
    return model{
        spinner: s,
        loading: true,
        items:   []string{},
    }
}

func (m model) View() string {
    if m.loading {
        return "\n" + m.spinner.View() + " Loading..."
    }
    
    s := titleStyle.Render("🧋 Bubble Tea Example")
    s += "\n\n"
    
    for i, item := range m.items {
        if m.cursor == i {
            s += selectedItemStyle.Render("▶ " + item) + "\n"
        } else {
            s += itemStyle.Render(item) + "\n"
        }
    }
    
    s += helpStyle.Render("\n↑/↓: navigate • q: quit")
    
    return s
}

Advanced Features

Async I/O

package main

import (
    "fmt"
    "net/http"
    "time"
    
    tea "github.com/charmbracelet/bubbletea"
)

type responseMsg struct {
    status int
    err    error
}

func checkServer(url string) tea.Cmd {
    return func() tea.Msg {
        client := &http.Client{Timeout: 5 * time.Second}
        resp, err := client.Get(url)
        if err != nil {
            return responseMsg{err: err}
        }
        defer resp.Body.Close()
        
        return responseMsg{status: resp.StatusCode}
    }
}

type model struct {
    url      string
    checking bool
    status   int
    err      error
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case responseMsg:
        m.checking = false
        m.status = msg.status
        m.err = msg.err
        return m, nil
        
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "r":
            m.checking = true
            return m, checkServer(m.url)
        }
    }
    
    return m, nil
}

Custom Renderer

package main

import (
    tea "github.com/charmbracelet/bubbletea"
)

// Custom renderer
type customRenderer struct {
    tea.StandardRenderer
    frames int
}

func (r *customRenderer) Render(v string) {
    r.frames++
    // Add FPS and performance info
    v += fmt.Sprintf("\n\nFrames: %d", r.frames)
    r.StandardRenderer.Render(v)
}

func main() {
    renderer := &customRenderer{
        StandardRenderer: tea.StandardRenderer{},
    }
    
    p := tea.NewProgram(
        initialModel(),
        tea.WithRenderer(renderer),
        tea.WithAltScreen(),
        tea.WithMouseCellMotion(),
    )
    
    if _, err := p.Run(); err != nil {
        log.Fatal(err)
    }
}

Using Bubbles Components

package main

import (
    "github.com/charmbracelet/bubbles/textinput"
    "github.com/charmbracelet/bubbles/viewport"
    "github.com/charmbracelet/bubbles/list"
    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    textInput textinput.Model
    viewport  viewport.Model
    list      list.Model
    ready     bool
}

func initialModel() model {
    // Text input
    ti := textinput.New()
    ti.Placeholder = "Type something..."
    ti.Focus()
    ti.CharLimit = 156
    ti.Width = 20
    
    // List
    items := []list.Item{
        item{"Item 1", "Description 1"},
        item{"Item 2", "Description 2"},
        item{"Item 3", "Description 3"},
    }
    l := list.New(items, itemDelegate{}, 20, 14)
    l.Title = "My List"
    l.SetShowStatusBar(false)
    l.SetFilteringEnabled(false)
    
    return model{
        textInput: ti,
        list:      l,
    }
}

Ecosystem

Official Tools

  • Glow: Markdown viewer
  • Soft Serve: Git server
  • VHS: Terminal recorder
  • Charm: Cloud services

Community Projects

  • lazygit: Git TUI
  • duf: Disk usage tool
  • gdu: Disk usage analyzer
  • ticker: Stock tracker

Advantages

  • Modern Design: Excellent design based on The Elm Architecture
  • Active Development: Continuous improvements by Charmbracelet
  • Rich Ecosystem: Comprehensive components and tools
  • Testability: Pure functions and immutability
  • Performance: Efficient rendering and updates

Limitations

  • Learning Curve: Requires understanding of The Elm Architecture
  • Framework-specific: Can be difficult to integrate with other approaches
  • Community Dependent: Relies on Bubbles components

Comparison with Other Libraries

FeatureBubble Teatviewtermui
ArchitectureElmWidget-basedDashboard
Learning CostMediumLowLow-Medium
EcosystemVery LargeLargeMedium
FlexibilityVery HighHighMedium
PerformanceVery HighHighHigh

Summary

Bubble Tea is the de facto standard for modern TUI development in Go. With its excellent design based on The Elm Architecture, active development community, and rich ecosystem, it's suitable for a wide range of applications from small tools to large-scale applications. It's particularly optimal for projects that prioritize maintainability and testability.