Learn Bubbletea Part 2
tldr
- Handle styles at the end
- Override styles with Inherit
- Use tea.WindowSizeMsg to get window dimensions
Overview
This part feels easy. Essentially trying to learn how to use lipgloss to style things.
I really like how this set of libraries are designed, feels very good to use.
Demo

Requirements
By Gemini
- Create a
lipgloss.Style for completed tasks (e.g., green, strikethrough).
- Create a
lipgloss.Style for the selected row (e.g., blue background, bold text).
- Add a bordered box around the entire application.
- Add a fixed footer at the bottom using
lipgloss.JoinVertical.
By myself on the fly
- Make sure that footer is at the bottom
- Make sure it adapts to the entire terminal
Implementation
The code is here, I’ll skip the details of each step, instead I’ll just talk about the parts that I found interesting.
Where to apply the styles?
After reading a bit about lipgloss, I thought it was simply define a style and apply it in View().
It is pretty much that but a few things still went wrong.
I tried to apply the styles after every if branch when I go over the tasks.
Didn’t feel good because I feel like I’m mixing the content with the style.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// Buggy and BAD
func (m model) View() tea.View {
log.Println(m)
var sb strings.Builder
cursor := " "
for i, task := range m.tasks {
style := styleBase
if i == m.cursor {
cursor = ">"
// how to add styles
style = style.Inherit(styleCurrentRow)
} else {
cursor = " "
}
marker := "[ ]"
// use taskID instead of i to help with delete
if _, ok := m.selected[task.id]; ok {
marker = "[x]"
s := fmt.Sprintf("%s%s %s\n", cursor, marker, task.name)
fmt.Fprintf(&sb, styleCompleted.Render(s))
} else {
marker = "[ ]"
s := fmt.Sprintf("%s%s %s\n", cursor, marker, task.name)
fmt.Fprintf(&sb, styleBase.Render(s))
}
}
fmt.Fprintf(&sb, "Press q to exit")
return tea.NewView(sb.String())
}
|
Apart from style.Render caused an issue rendering \n(See the next section), it feels wrong to do it this way.
Instead I created a copy of style at the top of View, and override (Inherit) the style as I go over the model.
Then in the end call style render once
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
func (m model) View() tea.View {
log.Println(m)
var sb strings.Builder
cursor := " "
for i, task := range m.tasks {
style := styleBase
if i == m.cursor {
cursor = ">"
// how to add styles
style = style.Inherit(styleCurrentRow)
} else {
cursor = " "
}
marker := "[ ]"
// use taskID instead of i to help with delete
if _, ok := m.selected[task.id]; ok {
marker = "[x]"
style = style.Inherit(styleCompleted)
} else {
marker = "[ ]"
}
row := fmt.Sprintf("%s%s %s", cursor, marker, task.name)
row = style.Render(row)
// put the \n after style.Render otherwise Render adds stuff
row += "\n"
fmt.Fprintf(&sb, row)
}
body := sb.String()
body = lipgloss.NewStyle().
Height(winHeight - footerHeight). // footerHeight is a constant that I set to 2, which is the height of the footer
Render(body)
footer := "Press q to exit"
// body's height was calculated by winHeight so footer is pushed to the bottom
all := lipgloss.JoinVertical(0.2, body, footer)
all = styleBorderedBox.Width(winWidth).Height(winHeight).Render(all)
return tea.NewView(all)
}
|
Issue calling Render with “\n”
The following is the buggy behavior caused by what I believe is that passing in a trailing “\n” into lipgloss’s Render function.
Still not 100% sure why this is an issue, might come back later for this.
The fix was to not pass “\n” in the end to Render

I was trying to use JoinVertical to join the body and footer together, it did work but it used the content’s width as the bounding box.
So I gave myself an additional challenge to
- Make sure that footer is at the bottom
- Make sure it adapts to the entire terminal
lipgloss is a pure styling library, it does not know the current terminal’s width and height.
To get that I needed to listen to the WindowSizeMsg inside Update and update the current window’s winWidth and winHeight.
1
2
3
4
5
6
7
8
9
10
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
winWidth = msg.Width
winHeight = msg.Height
// ...
}
}
|
Then I would utilize this information in my View function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func (m model) View() tea.View {
// ...
body := sb.String()
body = lipgloss.NewStyle().
Height(winHeight - footerHeight).
Render(body)
footer := "Press q to exit"
// body's height was calculated by winHeight so footer is pushed to the bottom
all := lipgloss.JoinVertical(0.2, body, footer)
all = styleBorderedBox.Width(winWidth).Height(winHeight).Render(all)
return tea.NewView(all)
}
|
Update: 2026-04-12
See Also