Leo Reasoning Animations
Pendo
2026
Overview
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.
That star is not a video. It is the real mark running the real Tier 1 thinking state, keyframes intact.
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.
01 · Animate the real thing
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.
02 · Explore wide, then cut
I built a state library across three tabs, minimal through experimental through exploration, and put every idea in it. Thinking, querying, generating, building a visualization, error, complete, listen, stop.
Then I cut it down. interrupted went first as redundant with stop.
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.
03 · Accessibility decided the design
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 differed in shape and rhythm instead, which survives being seen badly.
The star also stays one size throughout. It was tempting to scale it per state, but the star sits inline next to text, and a mark that changes size shoves the line around it.
Everything honours prefers-reduced-motion.
04 · Shipping it, and cutting more
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.
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.
05 · The number I marked as a guess
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.
Reflection
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.

