A reusable Bubble Tea component for hierarchical navigation using Miller columns (like macOS Finder's column view).
- Hierarchical Navigation: Display multiple levels side-by-side for visual context
- Generic: Works with any
list.Itemtype and hierarchical data structure - Focus Management: Navigate between columns with keyboard shortcuts
- Responsive: Automatically adjusts column widths to fit terminal size
- Customizable: Configure colors, borders, and number of visible columns
- Zero Dependencies: Only requires
bubbles,bubbletea, andlipgloss
go get github.com/blackwell-systems/bubbletea-millercolumnspackage main
import (
"github.com/blackwell-systems/bubbletea-millercolumns"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
mc millercolumns.Model
}
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 "enter", "right":
// Navigate into selected item
col := m.mc.FocusedColumn()
if col != nil {
selectedItem := col.List.SelectedItem()
if isNavigable(selectedItem) {
// Create new list for child items
items := getChildItems(selectedItem)
newList := createListModel(items)
m.mc.PushColumn(getItemID(selectedItem), newList)
}
}
case "left", "backspace":
// Go back to parent
m.mc.PopColumn()
case "tab":
// Switch focus to next column
m.mc.FocusNext()
case "shift+tab":
// Switch focus to previous column
m.mc.FocusPrev()
}
}
// Update the miller columns model
var cmd tea.Cmd
m.mc, cmd = m.mc.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.mc.View()
}
func main() {
// Create initial column
items := getRootItems()
initialList := createListModel(items)
// Create miller columns model
mc := millercolumns.New(millercolumns.Config{
MaxVisibleColumns: 3,
})
mc.PushColumn("root", initialList)
// Run the program
p := tea.NewProgram(model{mc: mc})
p.Run()
}package main
import (
"os"
"path/filepath"
"github.com/blackwell-systems/bubbletea-millercolumns"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FileItem represents a file or directory
type FileItem struct {
name string
path string
isDir bool
}
func (f FileItem) Title() string { return f.name }
func (f FileItem) Description() string { return "" }
func (f FileItem) FilterValue() string { return f.name }
type browserModel struct {
mc millercolumns.Model
}
func (m browserModel) Init() tea.Cmd {
return nil
}
func (m browserModel) 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 "enter", "right", "l":
// Navigate into directory
col := m.mc.FocusedColumn()
if col != nil {
if item, ok := col.List.SelectedItem().(FileItem); ok && item.isDir {
// Load directory contents
items := loadDirectory(item.path)
newList := list.New(items, list.NewDefaultDelegate(), 0, 0)
newList.Title = filepath.Base(item.path)
m.mc.PushColumn(item.path, newList)
}
}
case "left", "backspace", "h":
m.mc.PopColumn()
case "tab":
m.mc.FocusNext()
case "shift+tab":
m.mc.FocusPrev()
}
}
var cmd tea.Cmd
m.mc, cmd = m.mc.Update(msg)
return m, cmd
}
func (m browserModel) View() string {
return m.mc.View()
}
func loadDirectory(path string) []list.Item {
entries, _ := os.ReadDir(path)
items := make([]list.Item, 0, len(entries))
for _, entry := range entries {
items = append(items, FileItem{
name: entry.Name(),
path: filepath.Join(path, entry.Name()),
isDir: entry.IsDir(),
})
}
return items
}
func main() {
// Create initial column for home directory
home, _ := os.UserHomeDir()
items := loadDirectory(home)
initialList := list.New(items, list.NewDefaultDelegate(), 0, 0)
initialList.Title = filepath.Base(home)
// Create miller columns with custom styling
mc := millercolumns.New(millercolumns.Config{
MaxVisibleColumns: 3,
FocusedBorderColor: lipgloss.Color("6"), // Cyan
UnfocusedBorderColor: lipgloss.Color("240"), // Gray
})
mc.PushColumn(home, initialList)
p := tea.NewProgram(browserModel{mc: mc}, tea.WithAltScreen())
p.Run()
}mc := millercolumns.New(millercolumns.Config{
MaxVisibleColumns: 3, // Show up to 3 columns
FocusedBorderColor: lipgloss.Color("6"), // Cyan border for focused
UnfocusedBorderColor: lipgloss.Color("240"), // Gray for unfocused
BorderStyle: myCustomBorderStyle, // Optional custom border
})// Add a new column to the right
mc.PushColumn("column-id", listModel)
// Remove rightmost column
removed := mc.PopColumn() // returns false if only one column remains
// Replace a column at specific index
mc.ReplaceColumn(0, "new-id", newListModel)
// Get all columns
columns := mc.Columns()
// Get focused column
col := mc.FocusedColumn() // returns *Column or nil// Move focus right
moved := mc.FocusNext() // returns true if focus moved
// Move focus left
moved := mc.FocusPrev() // returns true if focus moved
// Get focused column index
index := mc.FocusedIndex()func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle navigation keys yourself, then:
var cmd tea.Cmd
m.mc, cmd = m.mc.Update(msg)
return m, cmd
}
func (m model) View() string {
return m.mc.View()
}The parent component handles navigation decisions:
- Push column: When user selects a navigable item (directory, menu, etc.)
- Pop column: When user presses back/left
- Focus switching: When user presses tab/shift+tab
case "enter":
col := m.mc.FocusedColumn()
item := col.List.SelectedItem()
if canNavigateInto(item) {
childItems := loadChildren(item)
newList := createList(childItems)
m.mc.PushColumn(getID(item), newList)
}The model automatically handles tea.WindowSizeMsg and resizes all columns proportionally. Just pass messages through:
m.mc, cmd = m.mc.Update(msg)customBorder := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
Padding(1, 2)
mc := millercolumns.New(millercolumns.Config{
BorderStyle: customBorder,
FocusedBorderColor: lipgloss.Color("205"), // Hot pink
UnfocusedBorderColor: lipgloss.Color("238"), // Dark gray
})- Generic: Works with any
list.Item- the parent decides what's navigable - No callbacks: Parent handles navigation logic via key handling
- Composition: Each column is a standard
bubbles/list.Model - Stateless navigation: Parent maintains the navigation state, not the component
- Responsive: Automatically adjusts to terminal width changes
- File browsers (directories and files)
- Menu systems (categories → subcategories → items)
- Configuration editors (sections → settings → values)
- Tree viewers (nodes → children → grandchildren)
- Hierarchical data exploration
MIT License - see LICENSE for details.
