gitstack

grindlemire/go-tui code browser

16.3 KB markdown 539 lines 2026-03-23 · a47141f raw

Animation Patterns

What We're Building

We're going to build a screen that runs four animation techniques side by side: frame-cycling spinners, eased progress bars, a per-character color wave, and a pulsing border. A single OnTimer(16ms) callback drives all of them at 60fps.

Concepts used:

  • State (Guide 05): reactive State[T] for spinner frames, wave phase, and pulse phase
  • Watchers (Guide 09): OnTimer for the animation loop
  • Styling (Guide 03): tui.RGBColor, tui.NewGradient, tui.NewStyle, dynamic textStyle and borderStyle
  • Layout (Guide 04): nested flex containers, gap, padding

Project Setup

Create a new directory and initialize the module:

mkdir animation && cd animation
go mod init animation
go get github.com/grindlemire/go-tui

You'll create two files:

  • animation.gsx -- the component, helpers, and render template
  • main.go -- the entry point

The Animation Loop

Terminal animations boil down to updating state on a timer and letting the framework re-render. The simplest approach: one OnTimer callback that ticks at your target frame rate, with frame counting to run different animations at different speeds.

Create animation.gsx with the struct and timer:

package main

import (
    "fmt"
    "math"
    "strings"
    "time"

    tui "github.com/grindlemire/go-tui"
)

type animationApp struct {
    spinnerFrame *tui.State[int]
    wavePhase    *tui.State[float64]
    pulsePhase   *tui.State[float64]
    startTime    time.Time
    frame        int
    paused       bool
}

func AnimationApp() *animationApp {
    return &animationApp{
        spinnerFrame: tui.NewState(0),
        wavePhase:    tui.NewState(0.0),
        pulsePhase:   tui.NewState(0.0),
        startTime:    time.Now(),
    }
}

func (a *animationApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
    }
}

func (a *animationApp) Watchers() []tui.Watcher {
    return []tui.Watcher{
        tui.OnTimer(16*time.Millisecond, a.animate),
    }
}

func (a *animationApp) animate() {
    if a.paused {
        return
    }
    a.frame++

    if a.frame%5 == 0 {
        a.spinnerFrame.Update(func(v int) int { return v + 1 })
    }

    a.wavePhase.Update(func(v float64) float64 { return v + 0.05 })
    a.pulsePhase.Update(func(v float64) float64 { return v + 0.03 })
}

The timer fires every 16 milliseconds, giving roughly 60 frames per second. The animate callback increments a frame counter and updates three state values:

  • spinnerFrame advances every 5th frame (~80ms per step), which gives the spinners a readable pace.
  • wavePhase advances every frame for a fast-moving rainbow shift.
  • pulsePhase advances at a slower rate for a gentle breathing effect on the border.

The progress bars use time.Since(a.startTime) directly in the render method instead of state, so they run on wall clock time with no accumulation drift.

And main.go:

package main

import (
    "fmt"
    "os"

    tui "github.com/grindlemire/go-tui"
)

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(AnimationApp()),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Pattern 1: Spinners

The simplest animation pattern is cycling through an array of Unicode characters. Define several frame sets with different visual styles:

var spinnerDots = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
var spinnerLine = []string{"┤", "┘", "┴", "└", "├", "┌", "┬", "┐"}
var spinnerCircle = []string{"◜", "◠", "◝", "◞", "◡", "◟"}
var spinnerBraille = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}

Each array represents one full cycle. The render template indexes into them with modular arithmetic:

<span class="text-cyan">{spinnerDots[a.spinnerFrame.Get()%len(spinnerDots)]}</span>

The %len(...) wraps around at the end of the array, so the spinner loops forever. Different array lengths produce different cycle speeds from the same frame counter.

Pattern 2: Eased Progress Bars

A linear progress bar moves at a constant rate, which looks stiff. An easing function remaps the progress value so the bar starts slow, speeds up through the middle, and slows down again at the end.

func easeInOutCubic(t float64) float64 {
    if t < 0.5 {
        return 4 * t * t * t
    }
    return 1 - math.Pow(-2*t+2, 3)/2
}

The input t and the output are both in the range [0, 1]. Wrapping the linear progress value with easeInOutCubic(t) before passing it to the bar renderer produces visibly smoother motion.

For the bar itself, fractional block characters (▏▎▍▌▋▊▉█) give 8 sub-steps per character cell, making the fill transition look smooth instead of jumping one full block at a time:

var barBlocks = []string{" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}

func renderBar(value float64, width int) string {
    if value < 0 {
        value = 0
    }
    if value > 1 {
        value = 1
    }
    totalSteps := float64(width) * 8
    steps := int(value * totalSteps)
    fullBlocks := steps / 8
    remainder := steps % 8

    var b strings.Builder
    b.WriteString(strings.Repeat("█", fullBlocks))
    if fullBlocks < width {
        b.WriteString(barBlocks[remainder])
        b.WriteString(strings.Repeat("░", width-fullBlocks-1))
    }
    return b.String()
}

The progress timing uses wall clock time through a helper that cycles on a 4-second loop (3 seconds to fill, 1 second hold):

func progressT(elapsed time.Duration) float64 {
    const cycleDuration = 3.0
    const pauseDuration = 1.0
    total := cycleDuration + pauseDuration
    cycleTime := math.Mod(elapsed.Seconds(), total)
    if cycleTime >= cycleDuration {
        return 1.0
    }
    return cycleTime / cycleDuration
}

The render template shows both bars side by side so you can see the difference:

<div class="flex gap-1">
    <span class="font-dim w-8">Linear:</span>
    <span class="text-blue">{renderBar(progressT(time.Since(a.startTime)), 30)}</span>
    <span class="text-blue">{fmt.Sprintf("%3.0f%%", progressT(time.Since(a.startTime))*100)}</span>
</div>
<div class="flex gap-1">
    <span class="font-dim w-8">Eased:</span>
    <span class="text-green">{renderBar(easeInOutCubic(progressT(time.Since(a.startTime))), 30)}</span>
    <span class="text-green">{fmt.Sprintf("%3.0f%%", easeInOutCubic(progressT(time.Since(a.startTime)))*100)}</span>
</div>

Pattern 3: Per-Character Color Wave

Each character in the word "ANIMATIONS" gets its own color based on its position and a phase offset. As the phase advances every frame, the colors shift across the text like a rainbow wave.

The color computation converts HSL to RGB so we can rotate the hue evenly:

func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
    h = math.Mod(h, 360)
    if h < 0 {
        h += 360
    }
    c := (1 - math.Abs(2*l-1)) * s
    x := c * (1 - math.Abs(math.Mod(h/60, 2)-1))
    m := l - c/2

    var r, g, b float64
    switch {
    case h < 60:
        r, g, b = c, x, 0
    case h < 120:
        r, g, b = x, c, 0
    case h < 180:
        r, g, b = 0, c, x
    case h < 240:
        r, g, b = 0, x, c
    case h < 300:
        r, g, b = x, 0, c
    default:
        r, g, b = c, 0, x
    }
    return uint8((r + m) * 255), uint8((g + m) * 255), uint8((b + m) * 255)
}

func waveStyle(charIndex int, phase float64) tui.Style {
    hue := math.Mod(float64(charIndex)*36+phase*60, 360)
    r, g, b := hslToRGB(hue, 1.0, 0.6)
    return tui.NewStyle().Bold().Foreground(tui.RGBColor(r, g, b))
}

Each character is spaced 36 degrees apart in hue. The phase*60 term shifts the entire palette over time. In the template, a for loop renders each character as its own <span> with a computed textStyle:

<div class="flex gap-0">
    for i, ch := range waveChars {
        <span textStyle={waveStyle(i, a.wavePhase.Get())}>{ch}</span>
    }
</div>

Pattern 4: Pulsing Border

The color wave section's border oscillates between cyan and magenta using math.Sin() and the gradient API:

func pulseBorderStyle(phase float64) tui.Style {
    t := (math.Sin(phase) + 1) / 2
    color := tui.NewGradient(tui.Cyan, tui.Magenta).At(t)
    return tui.NewStyle().Foreground(color)
}

math.Sin() outputs a value from -1 to 1, which we remap to the 0 to 1 range. Gradient.At(t) interpolates between the two colors at that position, so the border fades back and forth between cyan and magenta. In the template, the borderStyle attribute accepts this computed style:

<div class="flex-col border-rounded p-1 gap-1" borderStyle={pulseBorderStyle(a.pulsePhase.Get())}>

Full Code

Here's the complete animation.gsx:

package main

import (
    "fmt"
    "math"
    "strings"
    "time"

    tui "github.com/grindlemire/go-tui"
)

var spinnerDots = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
var spinnerLine = []string{"┤", "┘", "┴", "└", "├", "┌", "┬", "┐"}
var spinnerCircle = []string{"◜", "◠", "◝", "◞", "◡", "◟"}
var spinnerBraille = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}

var waveChars = []string{"A", "N", "I", "M", "A", "T", "I", "O", "N", "S"}

func easeInOutCubic(t float64) float64 {
    if t < 0.5 {
        return 4 * t * t * t
    }
    return 1 - math.Pow(-2*t+2, 3)/2
}

var barBlocks = []string{" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}

func renderBar(value float64, width int) string {
    if value < 0 {
        value = 0
    }
    if value > 1 {
        value = 1
    }
    totalSteps := float64(width) * 8
    steps := int(value * totalSteps)
    fullBlocks := steps / 8
    remainder := steps % 8

    var b strings.Builder
    b.WriteString(strings.Repeat("█", fullBlocks))
    if fullBlocks < width {
        b.WriteString(barBlocks[remainder])
        b.WriteString(strings.Repeat("░", width-fullBlocks-1))
    }
    return b.String()
}

func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
    h = math.Mod(h, 360)
    if h < 0 {
        h += 360
    }
    c := (1 - math.Abs(2*l-1)) * s
    x := c * (1 - math.Abs(math.Mod(h/60, 2)-1))
    m := l - c/2

    var r, g, b float64
    switch {
    case h < 60:
        r, g, b = c, x, 0
    case h < 120:
        r, g, b = x, c, 0
    case h < 180:
        r, g, b = 0, c, x
    case h < 240:
        r, g, b = 0, x, c
    case h < 300:
        r, g, b = x, 0, c
    default:
        r, g, b = c, 0, x
    }
    return uint8((r + m) * 255), uint8((g + m) * 255), uint8((b + m) * 255)
}

func waveStyle(charIndex int, phase float64) tui.Style {
    hue := math.Mod(float64(charIndex)*36+phase*60, 360)
    r, g, b := hslToRGB(hue, 1.0, 0.6)
    return tui.NewStyle().Bold().Foreground(tui.RGBColor(r, g, b))
}

func pulseBorderStyle(phase float64) tui.Style {
    t := (math.Sin(phase) + 1) / 2
    color := tui.NewGradient(tui.Cyan, tui.Magenta).At(t)
    return tui.NewStyle().Foreground(color)
}

func progressT(elapsed time.Duration) float64 {
    const cycleDuration = 3.0
    const pauseDuration = 1.0
    total := cycleDuration + pauseDuration
    cycleTime := math.Mod(elapsed.Seconds(), total)
    if cycleTime >= cycleDuration {
        return 1.0
    }
    return cycleTime / cycleDuration
}

type animationApp struct {
    spinnerFrame *tui.State[int]
    wavePhase    *tui.State[float64]
    pulsePhase   *tui.State[float64]
    startTime    time.Time
    frame        int
    paused       bool
}

func AnimationApp() *animationApp {
    return &animationApp{
        spinnerFrame: tui.NewState(0),
        wavePhase:    tui.NewState(0.0),
        pulsePhase:   tui.NewState(0.0),
        startTime:    time.Now(),
    }
}

func (a *animationApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
    }
}

func (a *animationApp) Watchers() []tui.Watcher {
    return []tui.Watcher{
        tui.OnTimer(16*time.Millisecond, a.animate),
    }
}

func (a *animationApp) animate() {
    if a.paused {
        return
    }
    a.frame++

    if a.frame%5 == 0 {
        a.spinnerFrame.Update(func(v int) int { return v + 1 })
    }

    a.wavePhase.Update(func(v float64) float64 { return v + 0.05 })
    a.pulsePhase.Update(func(v float64) float64 { return v + 0.03 })
}

templ (a *animationApp) Render() {
    <div class="flex-col gap-1 p-1">
        <span class="font-bold text-gradient-cyan-magenta">Animation Patterns</span>

        <div class="flex-col border-rounded p-1 gap-1" borderStyle={tui.NewStyle().Foreground(tui.BrightBlack)}>
            <span class="font-bold text-cyan">1. Spinners (Frame Cycling)</span>
            <div class="flex gap-4">
                <div class="flex gap-1">
                    <span class="text-cyan">{spinnerDots[a.spinnerFrame.Get()%len(spinnerDots)]}</span>
                    <span class="font-dim">Dots</span>
                </div>
                <div class="flex gap-1">
                    <span class="text-green">{spinnerLine[a.spinnerFrame.Get()%len(spinnerLine)]}</span>
                    <span class="font-dim">Line</span>
                </div>
                <div class="flex gap-1">
                    <span class="text-yellow">{spinnerCircle[a.spinnerFrame.Get()%len(spinnerCircle)]}</span>
                    <span class="font-dim">Circle</span>
                </div>
                <div class="flex gap-1">
                    <span class="text-magenta">{spinnerBraille[a.spinnerFrame.Get()%len(spinnerBraille)]}</span>
                    <span class="font-dim">Braille</span>
                </div>
            </div>
        </div>

        <div class="flex-col border-rounded p-1 gap-1" borderStyle={tui.NewStyle().Foreground(tui.BrightBlack)}>
            <span class="font-bold text-cyan">2. Progress Bar (Easing)</span>
            <div class="flex gap-1">
                <span class="font-dim w-8">Linear:</span>
                <span class="text-blue">{renderBar(progressT(time.Since(a.startTime)), 30)}</span>
                <span class="text-blue">{fmt.Sprintf("%3.0f%%", progressT(time.Since(a.startTime))*100)}</span>
            </div>
            <div class="flex gap-1">
                <span class="font-dim w-8">Eased:</span>
                <span class="text-green">{renderBar(easeInOutCubic(progressT(time.Since(a.startTime))), 30)}</span>
                <span class="text-green">{fmt.Sprintf("%3.0f%%", easeInOutCubic(progressT(time.Since(a.startTime)))*100)}</span>
            </div>
        </div>

        <div class="flex-col border-rounded p-1 gap-1" borderStyle={pulseBorderStyle(a.pulsePhase.Get())}>
            <span class="font-bold text-cyan">3. Color Wave + Pulsing Border</span>
            <div class="flex gap-0">
                for i, ch := range waveChars {
                    <span textStyle={waveStyle(i, a.wavePhase.Get())}>{ch}</span>
                }
            </div>
            <span class="font-dim">Border oscillates between cyan and magenta via sin()</span>
        </div>

        <span class="font-dim">q quit</span>
    </div>
}

And the complete main.go:

package main

import (
    "fmt"
    "os"

    tui "github.com/grindlemire/go-tui"
)

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(AnimationApp()),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Generate and run:

tui generate ./...
go run .

The finished animation demo with all three sections running:

Animation Patterns screenshot

Next Steps

A few ways you could extend this:

  • Add a pause/resume toggle with a key binding (Events Guide)
  • Use tui.Batch() to group multiple state updates into a single render pass (State Guide)
  • Combine animations with scrollable content for a loading indicator above a log feed (Scrolling Guide)