After: the set running in Pendo's main UI, one state handing off to the next

Leo Reasoning Animations

Company Pendo

Year 2026

Type

Leo is Pendo's in-product AI assistant. When it was working, it showed you a spinner. A spinner says something is happening and nothing else, which is the wrong answer when the assistant might be reading your data, writing a paragraph, or building a chart.

I designed a set of motion states that say which. Then I built them into the production codebase.

Rather run it than read about it? The animation lab is at the end.

That star is not a video. It is the real mark running the real Tier 1 thinking state, keyframes intact.

2,172

lines added across 29 files in Pendo's main UI, under APP-163206, with tests and seven locales

Product design intern, Team Flux. Four weeks, June to July 2026. Solo designer and prototyper, with critique from the design team and a production review by six engineers.


The first decision was refusing to animate a lookalike. I pulled the actual Leo star out of source and checked its colour against Figma and the database, because the version floating around in mockups had drifted teal and the real mark is purple.

That sounds pedantic for a four week project. It mattered because the whole argument later was that this could ship, and an animation built on an approximation of the logo is a thing engineers have to rebuild rather than merge.

I set one rule and defended it for the rest of the project: every bit of motion happens through the star itself. No orbiting particles, no flying entities. Partly because it reads as more deliberate, mostly because a mark that animates in place is a component, and a mark plus a cloud of satellites is a scene.

I built a state library across three tabs, minimal through experimental through exploration, and put every idea in it. Around nineteen states, each with its own motion and, in the exploration tier, its own satellite graphics: progress arcs, scan lines, expanding rings.

Here are the ones that did not make it. Real keyframes, real timings, running now.

  • Idlebreathing, just enough to read as alive
  • Planningthree counted steps, each held
  • Clarifyinga head tilt, listening
  • Tool usetwo decisive pushes
  • Readinga scan across the input
  • Long runningsmaller and slower, no alarm
  • Awaiting youone peak, then it waits
  • Connectingarrives from nothing, overshoots, settles
  • Regeneratingcompress, breathe, spring back
  • Rate limitedfaded and slowed, receding
  • Interrupteda lateral snap to attention

Then I cut it down to five.

That is the part worth looking at, because most of these are good. Planning counts its steps out loud and I liked it. Clarifying is a head tilt and it is the most charming thing I made all summer. Connecting springs in from nothing and is genuinely satisfying.

None of that is a reason to ship a state. A state earns its place by being a thing the product can actually tell you is happening, and by being distinguishable from its neighbours when you are not looking directly at it. Most of these failed the first test: Leo has no signal for rate limited, no signal for awaiting confirmation, and no meaningful notion of connecting. They were animations for events that do not exist.

interrupted failed the second one, and it is the closest call in the set. It is a lateral snap to attention, and the lab notes it as distinct from a graceful stop, which it is. But a reader does not meet the two side by side. They meet one, once, mid-answer, and asking someone to distinguish interrupted from stopped in that moment is asking for a discrimination nobody performs. So it went first.

Judging these one at a time was misleading, because nobody meets one state. They meet a sequence. So I built a sequence player that mimics the real Leo chat panel and watched the states hand off to each other in something shaped like a real answer. Several transitions that looked fine alone were noise in a flow.

That lab, with every candidate and the sequence player, is at the end of this page.

The prettiest states leaned on a glow. Glow is close to useless as the only difference between two states if you have low vision, and it fails outright for some kinds of colour blindness, so states that differed only in glow intensity were not actually distinguishable states.

That killed several of my favourites. What replaced them is a rule about channels: every state owns a different axis of motion, and no two states are allowed to share one.

Generating owns the vertical. Artifact streaming owns the horizontal, a slow side-to-side drift. Building a chart owns scale, growing in three discrete steps with a brief hold on each, so it reads as assembling in stages from the shape of the movement alone. Thinking dips below its resting size, and querying is even fast taps. Once each state has its own axis, you can tell them apart with the colour thrown away, at a small size, in greyscale.

The rule that makes it hold is the one about glow. Glow stays, but capped at 0.22 opacity and only ever as an accent, never as the thing carrying the meaning. The moment a state needs the glow to be legible, it has failed and needs a different axis.

Two states do share an axis, and I let them, because they are opposites in character and cannot occur at once: artifact streaming is slow and smooth on the horizontal, error is fast and percussive on the same one. That exception is written into the code next to both, so the next person to add a state finds the rule and the one place it bends.

The star also rests at the same size in every state. It was tempting to leave states sitting at different sizes, but the star sits inline next to text, and a mark that settles larger shoves the line around it. Motion goes back to neutral every time.

The ambient colour drift is the same argument from the other side. It is about fourteen degrees of hue over thirteen seconds, small enough to be decoration, and it is decoration on purpose: motion carries all the meaning, so nobody who cannot see the drift loses anything. It honours the pause control and prefers-reduced-motion, as does everything else here.

I spiked the real Vue change on a branch to measure how big the diff would be, which turned a design proposal into a scoping conversation. Then I implemented it.

The decisions worth defending are the subtractions.

I cut the generating phase completely. Text is already arriving on screen, the Stop button is right there, and the reasoning section is collapsing. Three signals already say Leo is writing. A fourth, animating next to copy someone is trying to read, only costs them reading flow. The star going still became the signal that Leo moved from working to answering.

I deleted my own dev animation lab from the branch. It persisted state to localStorage and could have reactivated outside development. Killing your own toy is the correct call and an easy one to talk yourself out of.

I made the error state fire once instead of looping. An error bubble that shudders forever reads as broken software rather than a report of a problem.

The recommended set, as recorded from the lab for the team vote. Four shipped. The fifth is the one I argued against.

Thinking — dip and swell, no glow
Querying — staccato scan taps
Building a chart — stepped build
Error — shake and desaturate, one-shot in production
Generating — explored, cut before ship

Then the alignment. Headings broke the star's vertical offset twice over, a shorter line box plus a 16 pixel margin, which left the star sitting about 14 pixels high on any answer that led with a heading. I could have branched per content type. Instead I normalized the leading markdown block of a message, and measured the result at zero drift across 20 different leading block shapes. One exception is written into the ticket: a leading h1 stays 2.8 pixels off, because max() declines to tighten its taller line.

Long turns needed a reassurance notice, so I added one at 60 seconds, measured from the start of the turn and deliberately not reset by activity. A turn that streams a step every few seconds for two minutes needs the time expectation as much as a silent one does.

I do not know that 60 is right. Local backend latency was not representative of production, so the ticket says in writing that the threshold is a considered guess and names what would settle it.

Flagging which number is soft is worth about as much as the number.

The thing I would tell you in an interview is not the animation. It is that most of the work was removal, and that the removals were the parts I had to argue for.

The ticket is in Devel rather than Done, and the retry-on-error buttons were pulled out on purpose to land separately. The honest version of a shipped project usually has a tail like that.

One more, because it is the habit I am most attached to. I had a statistic in the Slack draft announcing this, attributed to a Stanford study. Before sending it I went looking for the source and could not stand it up, so I cut it. Nobody would have checked.

Want the whole thing rather than the five that shipped? The lab holds every candidate, with A/B/C toggles, the sequence player and a reduced motion switch.

The animation lab — every Tier 1 candidate with A/B/C toggles, a sequence player, and a reduced motion switch · open full screen

all work