Tools: Terminal UI: BubbleTea (Go) vs Ratatui (Rust)

Tools: Terminal UI: BubbleTea (Go) vs Ratatui (Rust)

Source: Dev.to

What is BubbleTea? ## What is Ratatui? ## Summary ## Useful links ## External Resources Two strong options for building terminal user interfaces today are BubbleTea (Go) and Ratatui (Rust). One gives you an opinionated, Elm-style framework; the other a flexible, immediate-mode library. This post sums up what each is, shows a minimal example for both, and suggests when to pick which. And yes, also gives some useful links. Crush UI (screenshot above) is implemented using BubbleTea framework. BubbleTea is a Go framework for TUIs based on The Elm Architecture. You describe your app with a model (state) and three pieces: Init (initial command), Update (handle messages, return new model and optional command), and View (render the UI as a string). The framework runs the event loop, turns keypresses and I/O into messages, and redraws when the model changes. So: what is BubbleTea? In short, it’s the fun, stateful way to build terminal apps in Go, with a single source of truth and predictable updates. BubbleTea is production-ready (v1.x), has tens of thousands of GitHub stars and thousands of known importers, and works inline, full-screen, or mixed. You typically pair it with Bubbles for components (inputs, viewports, spinners) and Lip Gloss for styling. If you’re building a Go TUI, a solid first step is to follow common Go project structure so your cmd/ and packages stay clear as the app grows. A minimal BubbleTea program looks like this: a model, Init, Update (handling key messages), and View returning a string. The runtime does the rest. Notable apps built with BubbleTea include Crush (Charm’s TUI-based AI coding agent), Glow, Huh, and many tools from the top trending Go projects ecosystem. For more complex Go apps you might add dependency injection and solid unit tests; the same ideas apply to BubbleTea models and commands. Ratatui is a Rust library for TUIs that uses immediate-mode rendering: each frame you describe the entire UI (widgets and layout), and Ratatui draws it. What is Ratatui? It’s a lightweight, unopinionated toolkit—it doesn’t impose an Elm-style model or a specific app structure. You keep your own state, run your own event loop (typically with crossterm, termion, or termwiz), and call terminal.draw(|f| { ... }) to render. So the difference between Elm-style and immediate mode is exactly this: in Elm-style the framework owns the loop and you react via Update/View; in immediate mode you own the loop and redraw the whole UI from current state every frame. Ratatui is used by 2,100+ crates and trusted by companies like Netflix (e.g. bpftop), OpenAI, AWS (e.g. amazon-q-developer-cli), and Vercel. Version 0.30.x is current, with strong docs and optional backends. It’s a good fit when you want full control over input and rendering, or when you’re already in the Rust ecosystem. A minimal Ratatui app: you init the terminal, run a loop that draws and then reads events, and restore the terminal on exit. So: when should I choose BubbleTea vs Ratatui? Pick BubbleTea when you want the fastest path to a polished TUI in Go, with a single model and clear Update/View, and when your team or ecosystem is Go (e.g. you’re already using Go ORMs or Ollama in Go). Pick Ratatui when you need maximum control, are in Rust, or are building performance-sensitive or resource-constrained TUIs; its immediate-mode design and optional backends gives that flexibility. Both are excellent choices; preferrend language and how much structure we want from the framework should drive the decision. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" ) type model struct { choices []string cursor int 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 := "What should we buy?\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) } return s + "\nPress q to quit.\n" } func main() { if _, err := tea.NewProgram(model{ choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, selected: make(map[int]struct{}), }).Run(); err != nil { fmt.Printf("error: %v", err) os.Exit(1) } } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" ) type model struct { choices []string cursor int 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 := "What should we buy?\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) } return s + "\nPress q to quit.\n" } func main() { if _, err := tea.NewProgram(model{ choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, selected: make(map[int]struct{}), }).Run(); err != nil { fmt.Printf("error: %v", err) os.Exit(1) } } COMMAND_BLOCK: package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" ) type model struct { choices []string cursor int 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 := "What should we buy?\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) } return s + "\nPress q to quit.\n" } func main() { if _, err := tea.NewProgram(model{ choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, selected: make(map[int]struct{}), }).Run(); err != nil { fmt.Printf("error: %v", err) os.Exit(1) } } COMMAND_BLOCK: use crossterm::event::{self, Event, KeyCode}; use ratatui::{prelude::*, widgets::Paragraph}; use std::time::Duration; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut terminal = ratatui::init(); loop { terminal.draw(|frame| { let area = frame.area(); frame.render_widget( Paragraph::new("Hello, Ratatui! Press q to quit.").alignment(Alignment::Center), area, ); })?; if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { break; } } } } ratatui::restore(); Ok(()) } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: use crossterm::event::{self, Event, KeyCode}; use ratatui::{prelude::*, widgets::Paragraph}; use std::time::Duration; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut terminal = ratatui::init(); loop { terminal.draw(|frame| { let area = frame.area(); frame.render_widget( Paragraph::new("Hello, Ratatui! Press q to quit.").alignment(Alignment::Center), area, ); })?; if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { break; } } } } ratatui::restore(); Ok(()) } COMMAND_BLOCK: use crossterm::event::{self, Event, KeyCode}; use ratatui::{prelude::*, widgets::Paragraph}; use std::time::Duration; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut terminal = ratatui::init(); loop { terminal.draw(|frame| { let area = frame.area(); frame.render_widget( Paragraph::new("Hello, Ratatui! Press q to quit.").alignment(Alignment::Center), area, ); })?; if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { break; } } } } ratatui::restore(); Ok(()) } - Top 19 Trending Go Projects on GitHub - January 2026 - Top 23 Trending Rust Projects on GitHub - January 2026 - Dependency Injection in Go: Patterns & Best Practices - Go SDKs for Ollama - comparison with examples - Comparing Go ORMs for PostgreSQL: GORM vs Ent vs Bun vs sqlc - Go Project Structure: Practices & Patterns - Go Unit Testing: Structure & Best Practices - Bubble Tea – The Elm Architecture for Go TUIs - Ratatui – Introduction - Ratatui – Rendering (immediate mode) - Charm Crush – TUI coding agent - Ratatui on crates.io