Please purchase the course to watch this video.

Full Course
Creating visual feedback in command-line interface (CLI) applications is essential for keeping users informed during long-running tasks, such as data fetching or heavy computations. One effective method to achieve this is by implementing a loading spinner, which serves as an animated progress indicator. The lesson outlines a straightforward approach to build a spinner in Go by defining a function that utilizes character sequences to simulate rotation. Key steps include establishing a loop to print these characters while managing control characters to overwrite the output line for seamless animation. To enhance user experience, the spinner runs concurrently with the task it indicates, employing Go routines and channels to allow for real-time updates. The lesson emphasizes potential improvements, such as creating a dedicated spinner type with configurable properties, thus enabling reusable components and further refining the user interface in future projects.
When building CLI applications, there's going to be a time where you're going to want to run a long-running task, whether it's:
- Fetching data from the internet
- Downloading files onto your users' machines
- Performing heavy computation
In those situations, it's often useful to provide visual feedback to the user that the application is performing some task, or doing something, in order to let them know that the application hasn't just frozen.
Web vs CLI Progress Indicators
When it comes to the web, this is usually achieved through something called a progress spinner or loading indicator. You can see examples on sites like LottieFiles.com with various progress indicators, the most simple being the standard spinner that you typically see on a button when you click it and something is loading.
CLI Examples in the Wild
When it comes to CLI applications, the same principle applies. For example, if you run:
npm install
You get a loading spinner that appears while packages are being downloaded and installed. This spinner displays a set of characters that create an animation effect.
Project Setup: Simulating Long-Running Tasks
Let's start with a simple project that simulates a long-running task:
Basic Long-Running Task Example
Project Structure:
spinner-demo/
├── main.go
└── go.mod
main.go (initial version):
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("Starting long-running task...")
// Simulate a 5-second task
time.Sleep(5 * time.Second)
fmt.Println("Finished!")
}
Testing the Basic Version:
go run main.go
# Output: Starting long-running task...
# (5 second wait)
# Output: Finished!
Problem: During those 5 seconds, the user has no indication that anything is happening!
Creating a UI Package
Let's create a reusable UI package for our CLI components:
Project Structure:
spinner-demo/
├── main.go
├── ui/
│ └── spinner.go
└── go.mod
ui/spinner.go:
package ui
import (
"fmt"
"os"
"time"
)
// StartSpinner starts a simple loading spinner
func StartSpinner() {
// Define animation frames
frames := []rune{'|', '/', '-', '\\'}
// Simple animation loop (20 iterations = ~2 seconds)
for i := 0; i < 20; i++ {
// Get current frame using modulo for cycling
currentFrame := frames[i%len(frames)]
// Print current frame
fmt.Printf("%c", currentFrame)
// Wait 100ms between frames
time.Sleep(100 * time.Millisecond)
}
}
Understanding the Animation
Animation Frames
Our spinner uses 4 characters that create a rotating effect:
|
- Vertical bar/
- Forward slash-
- Horizontal bar\
- Backslash (escaped as\\
)
Frame Cycling
currentFrame := frames[i%len(frames)]
This uses the modulo operator to cycle through frames:
i=0
:0%4=0
→|
i=1
:1%4=1
→/
i=2
:2%4=2
→-
i=3
:3%4=3
→\
i=4
:4%4=0
→|
(cycles back)
Testing the Basic Spinner
main.go (updated):
package main
import (
"fmt"
"time"
"./ui" // Replace with your module path
)
func main() {
fmt.Println("Starting long-running task...")
ui.StartSpinner() // This will run for ~2 seconds
fmt.Println("Finished!")
}
Problem: This prints characters one after another: |/-\|/-\|/-\...
instead of animating!
Fixing the Animation: Carriage Return
To create proper animation, we need to overwrite the current line instead of appending characters.
Understanding Carriage Return
Carriage Return (\r
) moves the cursor back to the beginning of the current line without advancing to the next line. This allows us to overwrite the current character.
Historical Context
The term "carriage return" comes from typewriters, where the carriage (holding the paper) would physically return to the start position.
Fixed Animation Code
ui/spinner.go (improved):
package ui
import (
"fmt"
"time"
)
func StartSpinner() {
frames := []rune{'|', '/', '-', '\\'}
for i := 0; i < 20; i++ {
currentFrame := frames[i%len(frames)]
// Use carriage return to overwrite current position
fmt.Printf("\r%c", currentFrame)
time.Sleep(100 * time.Millisecond)
}
// Clear the spinner when done
fmt.Printf("\r")
}
Testing the Improved Spinner
go run main.go
# Output: Starting long-running task...
# (Shows animated spinner for ~2 seconds)
# Output: Finished!
Now we have a proper spinning animation! ✅
Making it Asynchronous
Currently, our spinner runs sequentially - first the task waits, then the spinner runs. We want them to run concurrently.
The Goal: Concurrent Execution
// We want this behavior:
// 1. Start spinner (background)
// 2. Run long task (foreground)
// 3. Stop spinner when task completes
Using Goroutines and Channels
ui/spinner.go (asynchronous version):
package ui
import (
"fmt"
"time"
)
// StartSpinner starts an asynchronous spinner and returns a stop channel
func StartSpinner() chan struct{} {
// Create a channel to signal when to stop
done := make(chan struct{})
// Run spinner in a goroutine
go func() {
frames := []rune{'|', '/', '-', '\\'}
i := 0
for {
select {
case <-done:
// Channel was closed - stop spinning
fmt.Printf("\r") // Clear spinner
return
default:
// Continue spinning
currentFrame := frames[i%len(frames)]
fmt.Printf("\r%c", currentFrame)
time.Sleep(100 * time.Millisecond)
i++
}
}
}()
return done
}
Understanding the Asynchronous Pattern
Key Components:
- Channel Communication:
chan struct{}
is used for signaling (no data needed) - Goroutine: Runs the spinner in the background
- Select Statement: Non-blocking channel check with fallback
- Infinite Loop: Continues until stop signal received
main.go (using asynchronous spinner):
package main
import (
"fmt"
"time"
"./ui"
)
func main() {
fmt.Println("Starting long-running task...")
// Start spinner (returns immediately)
stopSpinner := ui.StartSpinner()
// Simulate long-running task (5 seconds)
time.Sleep(5 * time.Second)
// Stop the spinner
close(stopSpinner)
fmt.Println("Finished!")
}
Testing Concurrent Execution
go run main.go
# Output: Starting long-running task...
# (Shows animated spinner for 5 seconds while task runs)
# Output: Finished!
Perfect! Now the spinner runs during the long task, not after it.
Addressing Visual Artifacts
You might notice that sometimes the last spinner character appears before "Finished!" This happens because:
- Channel closing is asynchronous -
close(stopSpinner)
returns immediately - Goroutine cleanup takes time - The carriage return might not execute before the next print
- Race condition - "Finished!" might print before the spinner clears
The Problem Illustrated:
Starting long-running task...
|Finished! // ← Spinner character still visible!
Complete Working Example
Here's our complete spinner implementation with the current approach:
main.go:
package main
import (
"fmt"
"time"
"./ui" // Replace with your actual module path
)
func main() {
fmt.Println("Starting long-running task...")
// Start the spinner
stopSpinner := ui.StartSpinner()
// Simulate actual work
performLongTask()
// Stop the spinner
close(stopSpinner)
// Small delay to let spinner clean up (temporary solution)
time.Sleep(10 * time.Millisecond)
fmt.Println("Finished!")
}
func performLongTask() {
// Simulate different types of work
time.Sleep(5 * time.Second)
}
Alternative Spinner Animations
You can create different visual effects by changing the frames:
Different Animation Styles
// Original rotating bar
frames := []rune{'|', '/', '-', '\\'}
// Bouncing dots
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
// Simple dots
frames := []rune{'.', 'o', 'O', 'o'}
// Arrow rotation
frames := []rune{'↖', '↗', '↘', '↙'}
// Progress dots
frames := []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'}
Testing Different Animations
// ui/spinner.go (configurable frames)
func StartSpinnerWithFrames(frames []rune) chan struct{} {
done := make(chan struct{})
go func() {
i := 0
for {
select {
case <-done:
fmt.Printf("\r")
return
default:
currentFrame := frames[i%len(frames)]
fmt.Printf("\r%c", currentFrame)
time.Sleep(100 * time.Millisecond)
i++
}
}
}()
return done
}
// Usage
func main() {
bounceFrames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
stopSpinner := ui.StartSpinnerWithFrames(bounceFrames)
time.Sleep(3 * time.Second)
close(stopSpinner)
}
Homework Assignments
Now let's improve our spinner with some practical enhancements:
Assignment 1: Consistent Timing with time.Ticker
Problem: Using time.Sleep()
in the default case means timing is inconsistent if the printing code takes variable time.
Solution: Use time.Ticker
for consistent intervals.
// TODO: Implement using time.Ticker
func StartSpinnerWithTicker() chan struct{} {
done := make(chan struct{})
go func() {
frames := []rune{'|', '/', '-', '\\'}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
i := 0
for {
select {
case <-done:
fmt.Printf("\r")
return
case <-ticker.C:
// TODO: Implement frame display logic
// Hint: Use ticker.C channel instead of time.Sleep
}
}
}()
return done
}
Assignment 2: Create a Spinner Type
Goal: Abstract the spinner into a proper type with methods.
// TODO: Implement this structure
type Spinner struct {
frames []rune
interval time.Duration
done chan struct{}
// Add other fields as needed
}
// Constructor
func NewSpinner() *Spinner {
// TODO: Implement
}
// Start the spinner
func (s *Spinner) Start() {
// TODO: Implement
}
// Stop the spinner and clean up
func (s *Spinner) Stop() {
// TODO: Implement - this should solve the visual artifact issue
// Hint: Use sync primitives to wait for cleanup
}
// Usage example:
func main() {
spinner := ui.NewSpinner()
spinner.Start()
time.Sleep(5 * time.Second)
spinner.Stop() // Should cleanly stop and clear
fmt.Println("Finished!")
}
Assignment 3: Configurable Spinner Options
Goal: Make the spinner highly configurable.
type SpinnerConfig struct {
Frames []rune
Interval time.Duration
Writer io.Writer // Allow custom output destination
}
// Predefined animation sets
var (
SpinnerDefault = []rune{'|', '/', '-', '\\'}
SpinnerDots = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
SpinnerBounce = []rune{'.', 'o', 'O', 'o'}
SpinnerArrows = []rune{'↖', '↗', '↘', '↙'}
)
func NewSpinnerWithConfig(config SpinnerConfig) *Spinner {
// TODO: Implement with configuration options
}
// Usage:
func main() {
config := ui.SpinnerConfig{
Frames: ui.SpinnerDots,
Interval: 80 * time.Millisecond,
Writer: os.Stdout,
}
spinner := ui.NewSpinnerWithConfig(config)
spinner.Start()
// ... long task ...
spinner.Stop()
}
Testing Your Implementation
Create comprehensive tests for your spinner:
// spinner_test.go
package ui
import (
"bytes"
"testing"
"time"
)
func TestSpinnerBasic(t *testing.T) {
var buf bytes.Buffer
config := SpinnerConfig{
Frames: []rune{'|', '/', '-', '\\'},
Interval: 10 * time.Millisecond,
Writer: &buf,
}
spinner := NewSpinnerWithConfig(config)
spinner.Start()
time.Sleep(50 * time.Millisecond)
spinner.Stop()
output := buf.String()
if len(output) == 0 {
t.Error("Expected spinner output, got none")
}
}
func TestSpinnerCleanup(t *testing.T) {
// Test that spinner properly cleans up visual artifacts
// TODO: Implement
}
Real-World Usage Patterns
With HTTP Requests
func downloadFile(url string) error {
spinner := ui.NewSpinner()
spinner.Start()
defer spinner.Stop()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Process response...
return nil
}
With Database Operations
func migrateDatabase() error {
config := ui.SpinnerConfig{
Frames: ui.SpinnerDots,
Interval: 100 * time.Millisecond,
}
spinner := ui.NewSpinnerWithConfig(config)
spinner.Start()
defer spinner.Stop()
// Run migrations...
return nil
}
Summary
We've successfully created a loading spinner that provides visual feedback for long-running CLI tasks:
Key Concepts Learned:
- Visual Feedback - Essential for CLI user experience
- Carriage Return (
\r
) - Overwrites current line for animation - Goroutines - Enable concurrent execution
- Channels - Provide communication between goroutines
- Select Statements - Non-blocking channel operations
Benefits:
- User Experience - Clear indication that work is happening
- Concurrent Execution - Spinner runs while task executes
- Reusable Component - Can be used across different CLI applications
- Configurable - Different animations for different use cases
Next Steps:
Complete the homework assignments to create a robust, reusable spinner component. In the next lesson, we'll build on this UI library to implement a progress bar, which is better suited for tasks where you can track completion percentage.
Additional Resources
- Reference Implementation - Complete spinner implementation (check lesson resources)
- Stack Overflow Spinner Examples - Various animation frame sets
- YouTube Video - Detailed explanation of visual artifact solutions
- Terminal Control Sequences - Advanced cursor control techniques
Remember: Only check the reference implementation after attempting the homework yourself. The learning happens in the struggle! 💪
Consistent Timing with time.Ticker
Problem: Using time.Sleep()
in the default case means timing is inconsistent if the printing code takes variable time.
Solution: Use time.Ticker
for consistent intervals.
// TODO: Implement using time.Ticker
func StartSpinnerWithTicker() chan struct{} {
done := make(chan struct{})
go func() {
frames := []rune{'|', '/', '-', '\\'}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
i := 0
for {
select {
case <-done:
fmt.Printf("\r")
return
case <-ticker.C:
// TODO: Implement frame display logic
// Hint: Use ticker.C channel instead of time.Sleep
}
}
}()
return done
}
Create a Spinner Type
Goal: Abstract the spinner into a proper type with methods.
// TODO: Implement this structure
type Spinner struct {
frames []rune
interval time.Duration
done chan struct{}
// Add other fields as needed
}
// Constructor
func NewSpinner() *Spinner {
// TODO: Implement
}
// Start the spinner
func (s *Spinner) Start() {
// TODO: Implement
}
// Stop the spinner and clean up
func (s *Spinner) Stop() {
// TODO: Implement - this should solve the visual artifact issue
// Hint: Use sync primitives to wait for cleanup
}
// Usage example:
func main() {
spinner := ui.NewSpinner()
spinner.Start()
time.Sleep(5 * time.Second)
spinner.Stop() // Should cleanly stop and clear
fmt.Println("Finished!")
}
Configurable Spinner Options
Goal: Make the spinner highly configurable.
type SpinnerConfig struct {
Frames []rune
Interval time.Duration
Writer io.Writer // Allow custom output destination
}
// Predefined animation sets
var (
SpinnerDefault = []rune{'|', '/', '-', '\\'}
SpinnerDots = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
SpinnerBounce = []rune{'.', 'o', 'O', 'o'}
SpinnerArrows = []rune{'↖', '↗', '↘', '↙'}
)
func NewSpinnerWithConfig(config SpinnerConfig) *Spinner {
// TODO: Implement with configuration options
}
// Usage:
func main() {
config := ui.SpinnerConfig{
Frames: ui.SpinnerDots,
Interval: 80 * time.Millisecond,
Writer: os.Stdout,
}
spinner := ui.NewSpinnerWithConfig(config)
spinner.Start()
// ... long task ...
spinner.Stop()
}