bloom

Feedback effects

Shake rejected input, show skeleton placeholders that shimmer while loading, roll changing numbers, burst confetti, glitch a badge, spotlight a tour step, and scroll text that doesn't fit.

Some effects aren't entrances. They respond to something happening: input rejected, data loading, a number changing, a deploy succeeding. Each takes its own progress, usually from a one-shot anim.Value or a looping anim.Sequence.

The pattern for one-shot effects is the same each time:

  1. Keep an anim.Value that starts at 1, with a duration and linear easing.
  2. When the event happens, Play(0, 1) and return its command.
  3. In View, wrap the node with bloom.Transition only while the value is animating.

Shake rejected input

fx.Shake moves a node side to side with a decaying wobble. It's still at 0 and at 1.

shake: anim.NewValue(1, anim.Duration(450*time.Millisecond), anim.Ease(anim.Linear)),
// in Update, when the input is wrong
cmd = m.shake.Play(0, 1)

// in View
field := bloom.Child(input)
if m.shake.Animating() {
	field = bloom.Transition(m.shake.Float(), field, fx.Shake{Amount: 3, Times: 3})
}

Amount is how many cells it moves at most (2 by default), and Times is how many times it swings each way (4 by default).

Skeleton loading placeholders

bloom.Skeleton draws placeholder bars while data loads. Each number is a line: a bar that many cells wide, or 0 for a blank line. Give it the same number of lines as the content it stands in for, so nothing moves when the content arrives.

Shimmer sweeps a band of light across the bars. Its phase comes from a looping sequence in your model:

shimmer: anim.NewLoop(1400 * time.Millisecond),
func (m *model) Init() tea.Cmd {
	// fetchStats is your command that loads the data.
	return tea.Batch(m.ui.Init(), m.shimmer.Loop(), fetchStats)
}

// when the data arrives
m.loaded = true
m.shimmer.Stop()

// in View: four lines, as the loaded card has four
if !m.loaded {
	return bloom.Box(card, bloom.Skeleton(8, 0, 5, 11).Shimmer(m.shimmer.Float()))
}
return bloom.Box(card, bloom.Column(
	bloom.Text("Requests").Style(heading),
	bloom.Fixed(1, nil),
	bloom.Text(strconv.Itoa(m.requests)).Style(big),
	bloom.Text("per minute").Style(label),
))

Bars are drawn in the terminal's foreground faded most of the way into its background, so they suit any theme. Style colors them yourself, and bars wider than the space they're given are cut to fit.

The band starts just off one side and ends just off the other, so the loop restarts without a visible jump. It's fx.Shimmer, which works on any node. For a band of another color or width, or to shimmer a whole card, draw it in a transition instead:

bloom.Transition(m.shimmer.Float(), placeholder, fx.Shimmer{Color: accent, Width: 10})

Roll changing numbers

fx.Roll turns the digits of a number over like an odometer. Draw the new number, and give the effect the number shown before:

roll: anim.NewValue(1, anim.Duration(700*time.Millisecond), anim.Ease(anim.Linear)),
// when the number changes
m.previous, m.requests = m.requests, latest
cmd = m.roll.Play(0, 1)

// in View
num := bloom.Child(bloom.Text(strconv.Itoa(m.requests)))
if m.roll.Animating() {
	num = bloom.Transition(m.roll.Float(), num, fx.Roll{From: strconv.Itoa(m.previous)})
}

Digits are matched from the right and separators like commas are skipped, so going from 1,021 to 1,058 only turns the last two digits over. Roll works on the text, so format the number however you like, as long as From is formatted the same way.

Burst confetti

fx.Burst throws particles out from a point. Its progress is the burst's age, and nothing is drawn at 1. Apply it to the whole screen so particles can fly anywhere, starting from a button's position in the last frame:

burst: anim.NewValue(1, anim.Duration(1800*time.Millisecond), anim.Ease(anim.Linear)),
screen := bloom.Child(layout)
if m.burst.Animating() {
	if r, ok := m.ui.Frame().Rect(buttonDeploy); ok {
		screen = bloom.Transition(m.burst.Float(), screen, fx.Burst{
			At:    image.Pt((r.Min.X+r.Max.X)/2, r.Min.Y),
			Count: 90,
			Seed:  m.deploys,
		})
	}
}

Particles only change the glyph and color of cells they land on, and stay inside the node the effect is applied to.

Glitch a badge

fx.Glitch makes a node look like a breaking-up signal: rows jitter, colors split, and cells garble, fading to nothing at 1. It works well as an entrance for a status that just changed:

badge := bloom.Child(bloom.Text("LIVE").Style(live))
if m.badge.Animating() {
	badge = bloom.Transition(m.badge.Float(), badge, fx.Glitch{Seed: m.deploys})
}

Spotlight a tour

fx.Spotlight dims everything except a rectangle. As a layer's backdrop, it walks the user through a screen one part at a time. Put the explanation in a layer, and light the part it explains:

note := bloom.Layer(bloom.Box(tourStyle, bloom.Text(steps[m.step]))).
	Anchor(bloom.Center, bloom.End).
	Margin(2).
	Presence(m.tour, fx.Fade{})

if r, ok := m.ui.Frame().Rect(cardID(m.step)); ok {
	note = note.Backdrop(fx.Spotlight{Rect: r.Inset(-1), Soft: 5})
}

Soft is how many cells the dimming takes to reach full strength, for a soft edge. fx.Decode suits the explanation text, revealing it like digital rain settling into place.

Scroll text that doesn't fit

bloom.Marquee(s, phase) shows one line of text. When it fits, it's shown as is. When it doesn't, it's cut short with "…" while phase is 0, and scrolls around as phase runs from 0 to 1. That suits a long title in a list item, scrolling only while the item is selected:

name: anim.NewSequence(0,
	anim.Jump(0),
	anim.Hold(900*time.Millisecond), // wait a moment before scrolling
	anim.To(1, anim.Duration(7*time.Second)),
).With(anim.Ease(anim.Linear)),
// when an item is selected
cmd = m.name.Loop()

// in View
phase := 0.0
if i == m.selected {
	phase = m.name.Float()
}
bloom.Fill(bloom.Marquee(item.title, phase).Style(itemStyle))

For a news ticker that always scrolls, use anim.NewLoop(24 * time.Second) as the phase, and Gap to set what's shown between the end of the text and its start: bloom.Marquee(headlines, phase).Gap(" ◆ ").

Last updated on

On this page