---
title: "Спиннеры: индикаторы загрузки"
description: "bubbles spinner: готовые индикаторы загрузки для терминала на Go — пресеты Dot и Line, команда Tick, обработка TickMsg в Update и свой спиннер с нужным FPS."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/06-spinner/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 16
---

# Спиннеры: индикаторы загрузки

*Курс «Bubble Tea: TUI-приложения в терминале», урок 6 из 25. ~16 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/06-spinner/*

Спиннер в терминале на Go собирается за десять строк из пакета bubbles spinner: пресет Dot или Line, команда Tick и ветка TickMsg в Update. Разберём подключение компонента к модели, полный список пресетов, остановку тиков и собственный спиннер с нужным FPS — на примере программы, которая честно показывает ожидание.

Программа грузит данные десять секунд. Если экран молчит, пользователь решает, что всё зависло, жмёт ctrl+c и уходит — интерфейс виноват, даже если работа шла как надо. Отсюда правило: любая асинхронная операция длиннее секунды обязана быть видимой. В терминале для этого есть спиннер — вращающаяся палочка, брайль-точки или мигающее многоточие, — и в Bubble Tea его не рисуют руками: пакет bubbles привозит готовый компонент со встроенной анимацией, стилями и честным поведением при остановке. Десять строк кода — и программа перестаёт выглядеть зависшей; в этом уроке разберём все десять. Приятно, что механика под капотом — та самая, с прошлого урока: спиннер тикает командой tea.Cmd и приходит сообщением. То есть это ещё и разминка перед большими асинхронными программами: спиннер — маленькая модель внутри вашей, со своим состоянием и своим циклом. Заодно позаботимся о пользователе: молчаливое ожидание — это баг интерфейса, и спиннер — самый дешёвый способ его закрыть.

### bubbles/spinner: готовый компонент для терминала

bubbles — официальный набор компонентов Charm, и спиннер — самый маленький его жилец: пакет github.com/charmbracelet/bubbles/spinner. Идея та же, что у всей библиотеки: компонент — это модель со своими Update и View, которую вы встраиваете в свою модель полем и кормите сообщениями. Встраивание — не наследование: переопределять ничего не нужно, вы храните значение и сами решаете, какие сообщения ему достанутся — этот подход целиком разберёт восьмой урок. spinner.New() возвращает начальный спиннер с пресетом Line; чтобы выбрать другой, присвойте поле Spinner — s.Spinner = spinner.Dot. У модели есть и поле Style для цвета, но о цвете поговорим на уроке про Lip Gloss, пока достаточно и без стилей. Если вы уже трогали другие bubbles-компоненты, схема узнается с первого взгляда — и наоборот, спиннер станет образцом для всего набора. Дальше по плану: рассмотреть пресеты, разобрать команду Tick, которая запускает анимацию, и главный практический навык урока — как спиннер останавливать, потому что вечно крутящийся индикатор врёт пользователю ровно так же, как молчащий экран.

### Подключаем спиннер к модели

```go
import (
        "github.com/charmbracelet/bubbles/spinner"
)

type model struct {
        spinner spinner.Model
        loading bool
}

func initialModel() model {
        s := spinner.New()
        s.Spinner = spinner.Dot
        return model{spinner: s, loading: true}
}
```

Поле spinner хранит вложенную модель: у неё свои Update и View, но вызывать их будет ваша модель. spinner.New() берёт пресет Line по умолчанию, поэтому сразу меняем поле Spinner на Dot — крупные брайль-точки, самый заметный вариант. Флаг loading потребуется дальше, чтобы отличать рабочее состояние от завершённого.

### Пресеты spinner.Spinner

| Пресет | Кадры | FPS | Где уместен |
| --- | --- | --- | --- |
| spinner.Line | | / - \ | 10 | минимализм, работает в любом шрифте |
| spinner.Dot | ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ | 10 | крупный брайль, классика bubbles |
| spinner.MiniDot | ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ | 12 | компактный брайль для строк справки |
| spinner.Points | ∙∙∙ ●∙∙ ∙●∙ ∙∙● | 7 | ненавязчивое ожидание внутри текста |
| spinner.Meter | ▱▱▱ ▰▱▱ ▰▰▱ ▰▰▰ | 7 | уровень вместо вращения |
| spinner.Ellipsis | пусто, ., .., ... | 3 | суффикс к надписи: Сохраняю... |

### spinner.Tick: команда, которая тикает

Анимация спиннера — это цепочка асинхронных команд из прошлого урока. В модели spinner есть поле Tick — готовая команда первого тика: Init возвращает её, фреймворк запускает, и через промежуток FPS в Update прилетает spinner.TickMsg. Дальше ваша очередь: переслать сообщение вложенной модели — m.spinner, cmd = m.spinner.Update(msg) — и вернуть полученную команду наружу. Внутри она планирует следующий тик, тот снова приходит TickMsg, цепочка замыкается: спиннер крутится, пока Update исправно возвращает команды. Это тот же приём самозапланированной команды, что вы поймали в predict прошлого урока, только закрытый в компоненте. Заметьте, что тик — обычное сообщение: он проходит через ваш switch наравне с клавишами, поэтому вы всегда можете вмешаться — замедлить, пропустить или не вернуть команду вовсе. Схема одинакова для всех тикающих компонентов — часов, таймеров, прогресс-баров: учась на спиннере, вы учитесь всему классу. Заметьте деталь: пересылка сообщения и возврат команды — два отдельных действия, и оба нужны. Забудете вернуть — анимация умрёт; забудете переслать — кадр перестанет меняться, хотя тики продолжат приходить. О том, как цепочку разрывать, — чуть ниже, это главный навык урока.

### Программа целиком: тики, работа, остановка

```go
type workDoneMsg struct{ report string }

func workCmd() tea.Cmd {
        return func() tea.Msg {
                time.Sleep(2 * time.Second) // имитация долгой работы
                return workDoneMsg{report: "17 файлов обработано"}
        }
}

func (m model) Init() tea.Cmd {
        return m.spinner.Tick
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.KeyMsg:
                switch msg.String() {
                case "q", "ctrl+c":
                        return m, tea.Quit
                case "s":
                        m.loading = true
                        return m, workCmd()
                }
        case spinner.TickMsg:
                if m.loading {
                        var cmd tea.Cmd
                        m.spinner, cmd = m.spinner.Update(msg)
                        return m, cmd
                }
                return m, nil
        case workDoneMsg:
                m.loading = false
                return m, nil
        }
        return m, nil
}

func (m model) View() string {
        if m.loading {
                return m.spinner.View() + " Работаю..."
        }
        return "Готово\n"
}
```

Init запускает только первый тик; по нажатию s стартует workCmd — с прошлого урока вы знаете, что она выполнится в горутине фреймворка. Пока loading равен true, каждый TickMsg пересылается вложенной модели, и её новая команда уходит наружу — анимация живёт. workDoneMsg выключает флаг, ветка TickMsg перестаёт возвращать команду, цепочка тиков рвётся: спиннер замирает. Запускайте локально: настоящий терминал покажет и вращение, и честную остановку.

> **Спиннер, который крутится вечно.** Частая ошибка — продолжать пересылать TickMsg и возвращать команды после завершения работы: индикатор бесконечно крутится рядом с «Готово» и разрушает доверие. Остановка не требует метода: перестаньте возвращать команду следующего тика — цепочка умрёт сама.

### Свой спиннер и FPS

Пресет — это просто значение типа spinner.Spinner с двумя полями: Frames — срез строк-кадров и FPS — частота. Никакой магии, поэтому свой спиннер собирается одной переменной: var slowRing = spinner.Spinner{Frames: []string{"◐", "◓", "◑", "◒"}, FPS: time.Second / 2} — и присваивается модели как обычный пресет. Частота подбирается по характеру ожидания: десять кадров в секунду — живое вращение, три-четыре — степенное, «мы работаем, не торопимся». Пара грабель. Брайль-кадры требуют шрифта с поддержкой брайля: на дежурной моноширинной машине вы получите квадратики вместо точек. Эмодзи-кадры вроде глобуса занимают двойную ширину и рвут вёрстку строк. Я по умолчанию беру Line: скучно, зато предсказуемо в любом терминале; выразительные пресеты оставляю для случаев, когда точно знаю аудиторию. Полный список пресетов с их FPS живёт в исходнике пакета — spinner.go, он короткий.

### Четыре состояния загрузки

Загрузка — это машина состояний, и спиннер — только её видимая часть. Минимальный набор: idle — работы нет, экран спокойный; loading — идёт, индикатор крутится; done — результат показан; error — ошибка видна и повтор доступен. Пара полей модели покрывает всё: loading bool и err error, как в прошлом уроке. Ошибки проектирования начинаются, когда состояния смешивают: спиннер крутится поверх уже показанных данных, «Готово» рисуется рядом с ошибкой, повторная загрузка не сбрасывает старый результат. Проверяйте себя простым правилом: каждая ветка View отвечает ровно одному состоянию, а переходы делаются только в Update при приходе сообщений — workDoneMsg, tasksLoadedMsg, errMsg. Если один и тот же переход случается в двух местах, вы уже потеряли контроль. И помните: повторная операция обязана снова включить loading и обнулить прошлый результат — забытый сброс даёт интерфейс, который показывает старое поверх нового.

### Строка ожидания: спиннер плюс текст

Одинокая палочка малоинформативна, поэтому спиннер склеивают с надписью: m.spinner.View() + " Загружаю задачи..." — View возвращает текущий кадр строкой, дальше обычная конкатенация. Рядом уместно писать, что именно происходит: «Проверяю токен», «Скачиваю пакет 2 из 9» — чем конкретнее строка, тем спокойнее пользователь. Цвет и отступы добавит поле Style, когда дойдём до Lip Gloss. Отдельный вопрос — куда влезает спиннер: в инлайн-режиме кадр перерисовывается на месте, и постоянное вращение дёргает вывод, если интерфейс длиннее пары строк; полноэкранным программам с альтернативным экраном это не грозит. И не забывайте про честность: спиннер без слова «отмена» — это спиннер, который нельзя отменить; кнопку Esc по контексту из прошлого урока стоит держать в голове с первого дня. Практика из жизни: строчку статуса полезно менять на этапах — «Подключаюсь», «Разбираю ответ», «Сохраняю» — пользователь читает её как прогресс, даже без процентов.

### Когда спиннер — неправильный ответ

Спиннер — инструмент для неопределённого ожидания: вы не знаете, сколько продлится операция. Когда доля выполненного известна — копирование файла по байтам, загрузка девяти пакетов, — честнее показать полосу прогресса: проценты снимают тревогу лучше любого вращения. Когда операция короче полсекунды, индикатор вообще вреден: он успевает мигнуть и создаёт ощущение дёрганья; классическое правило интерфейсов — показывать ожидание не сразу, а после задержки в 300–500 миллисекунд, и терминал здесь не исключение. Наконец, когда пользователь ждёт осмысленно — установка пакета на пять минут, — спиннер плюс этапы информативнее одного процента: люди предпочитают понимать, где они в процессе. Выбирайте индикатор по природе ожидания, а не по красоте пресета — и интерфейс станет честнее без единой новой зависимости.

### Сколько стоит анимация

Десять кадров в секунду звучит дорого, и для спиннера это неправда: рендерер Bubble Tea сравнивает новый кадр со старым и обновляет только изменившиеся строки — одна строка с палочкой почти ничего не стоит. Но View целиком пересчитывается при каждом сообщении, включая тики: если View строит гигантскую строку на тысячи позиций — таблицу, лог — вы будете пересобирать её десять раз в секунду только ради палочки. Симптом знакомый: спиннер тормозит интерфейс, хотя «ничего не делает». Лечение простое: держите View дешёвым — тяжёлые куски собирайте один раз в полях модели при изменении данных, а на каждый кадр только склеивайте готовое. Второй рычаг — FPS: для медленных операций степенный Ellipsis на трёх кадрах информативен не хуже быстрого Dot. Замеряйте на своём железе: у теории дешёвых перерисовок, как и у всего, есть предел применимости.

> **Несколько спиннеров сразу.** TickMsg несёт поле ID — идентификатор спиннера, породившего тик. Когда спиннеров несколько, по ID решают, чьё это сообщение, и пересылают его только владельцу, иначе один тик ускорит всех.

### Проверка: запуск анимации

**Вопрос:** Что нужно вернуть из Init, чтобы спиннер начал крутиться?

- m.spinner.Update(nil)
- ✅ m.spinner.Tick
- spinner.Dot
- Ничего: спиннер крутится сам по себе

Поле Tick хранит готовую команду первого тика. Дальше цепочку поддерживает Update: пересылаете TickMsg вложенной модели и возвращаете её команду следующего тика. Типичная ошибка — вернуть вызов m.spinner.Update(nil): это метод, а не команда, и первым аргументом туда передают сообщение, а не nil.

### Проверка: остановка

**Вопрос:** Работа закончилась. Как штатно остановить спиннер?

- Вызвать метод m.spinner.Stop()
- Присвоить s.Spinner = nil
- ✅ Перестать возвращать команду следующего тика из Update
- Перезапустить программу

Метода Stop у модели нет: анимация живёт, пока Update пересылает TickMsg и возвращает новую команду тика. Разорвали цепочку — спиннер замер на последнем кадре.

### Что покажет View после завершения?

```go
case spinner.TickMsg:
        if m.loading {
                var cmd tea.Cmd
                m.spinner, cmd = m.spinner.Update(msg)
                return m, cmd
        }
        return m, nil
case workDoneMsg:
        m.loading = false
        return m, nil

// workDoneMsg пришёл. Что будет с экраном дальше?
```

**Что выведет программа?**

- ✅ Спиннер замирает на последнем кадре
- Спиннер продолжает крутиться
- View перестаёт вызываться
- Спиннер мигает раз в секунду

После workDoneMsg ветка TickMsg возвращает nil: новые тики не планируются, сообщение больше не приходит. View рисует последний полученный кадр — статичный индикатор. Это штатная остановка, а не баг.

### Заполните проводку спиннера

Восстановите запуск тика и обработку сообщения спиннера в Update.

```go
func (m model) Init() tea.Cmd {
        return m.spinner.___
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        case spinner.___:
                var cmd tea.Cmd
                m.spinner, cmd = m.spinner.Update(msg)
                return m, cmd
}
```

Подсказка: Из Init возвращают команду первого тика — поле Tick; в Update сообщение спиннера ловится по типу spinner.TickMsg.

Правильные ответы в пропусках: `Tick`, `TickMsg`

### Соберите инициализацию модели

Расставьте строки создания спиннера и начальной модели в правильном порядке.

Правильный порядок строк:

1. `func initialModel() model {`
2. `  s := spinner.New()`
3. `  s.Spinner = spinner.Dot`
4. `  return model{spinner: s, loading: true}`
5. `}`
6. `func (m model) Init() tea.Cmd { return m.spinner.Tick }`

### Карточки: словарь спиннера

- **spinner.Model** — Вложенная модель из bubbles: поле Spinner — пресет, Style — стиль, Tick — команда первого тика. Кормится TickMsg через Update.
- **spinner.Spinner** — Значение-пресет: срез кадров Frames и частота FPS. Line, Dot, MiniDot и другие; свой спиннер — просто своя пара Frames + FPS.
- **spinner.TickMsg** — Сообщение тика. Пересылается в m.spinner.Update, которая возвращает команду следующего тика — так живёт цепочка анимации.
- **Остановка спиннера** — Не возвращать команду тика из Update: цепочка рвётся, View показывает последний кадр.
- **FPS** — Частота кадров пресета: 10 у Dot и Line, 12 у MiniDot, 3 у Ellipsis. Подбирается по характеру ожидания.

### Что дальше

Спиннер закрыл вопрос «как показать ожидание», но в примере выше работу запускало нажатие клавиши — потому что Init умеет возвращать лишь одну команду. Как запустить тики и загрузку одновременно, объединяя команды в пачки, — тема [седьмого урока про tea.Batch и tea.Sequence](/courses/bubbletea/lessons/07-batch-sequence/). Когда ожидание измеримо в процентах, вращение меняется на полосу: [прогресс-бары bubbles](/courses/bubbletea/lessons/12-progress/) покажут долю сделанного. А вернуть былую механику можно перечитав [пятый урок про tea.Cmd](/courses/bubbletea/lessons/05-tea-cmd/) — вся анимация спиннера построена на нём. Программа курса целиком — на странице [курса Bubble Tea](/courses/bubbletea/). Домашнее задание сделайте в терминале: замените Dot на свой пресет из трёх кадров и подберите FPS так, чтобы вращение не раздражало за десять секунд.

## Коротко: что запомнить

- bubbles/spinner — вложенная модель: New(), пресет в поле Spinner, View() возвращает кадр.
- Анимация — цепочка команд: Init возвращает m.spinner.Tick, Update пересылает TickMsg и возвращает следующую команду.
- Остановка — разрыв цепочки: не возвращайте команду тика, и спиннер замрёт на последнем кадре.
- Пресет — это Frames плюс FPS; свой спиннер собирается одной переменной.
- Брайль требует шрифта, эмодзи ломают ширину — Line безопаснее всего.
- Рядом со спиннером пишите, что происходит: конкретная строка успокаивает лучше вращения.

## Домашнее задание

Соберите программу урока локально и доработайте её: добавьте свой пресет из трёх кадров с FPS в одну секунду, а надпись рядом со спиннером сделайте зависящей от этапа — «Подключаюсь», «Скачиваю», «Разбираю». Затем сломайте остановку нарочно: верните команду тика даже после workDoneMsg и посмотрите, как ведёт себя «Готово» рядом с вечно вращающимся индикатором. Почините обратно.

## Вопросы и ответы по уроку

### Спиннер в bubbletea требует своих горутин?

Нет. Тики приходят через команды tea.Cmd, которые фреймворк запускает сам: Init возвращает m.spinner.Tick, а Update пересылает spinner.TickMsg вложенной модели и возвращает её команду следующего тика. Своего цикла у компонента нет — общий.

### Почему спиннер не останавливается вместе с работой?

Потому что кто-то продолжает возвращать команду тика из Update. Проверьте ветку spinner.TickMsg: после завершения работы она должна возвращать nil, тогда цепочка анимации рвётся и индикатор замирает. Частый виновник — ветка default, которая бездумно пересылает всё в m.spinner.Update и возвращает команду.

### Какой пресет выбрать по умолчанию?

Line — работает в любом шрифте и терминале. Dot и MiniDot красивее, но требуют брайля; эмодзи-пресеты рвут вёрстку на терминалах с одинарной шириной символов.
