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 live: 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, alignment with the designers working on Leo's other surfaces, 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
It started as two states mocked in Figma. Design Jarvis took those as the reference and multiplied them out into a full library across three tabs, minimal through experimental through exploration, and I put every idea that came back through the same bar. 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.
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 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 grows in two unequal beats, 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 stage, and the three positions it actually reports.
- swings in1.05×-5°
- clicks to true1.06×0°
- swings back1.10×4°
- true again1.11×0°
- last swing1.15×-4°
- assembled1.16×0°
The rule that makes it hold is the one about glow. Glow stays, but capped at 0.55 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.
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, with Design Jarvis writing most of the actual Vue against rules I set, and I reviewed it line by line before six engineers did the same. The 2,172 lines above are mine in the sense that matters, not in the sense that I typed each one.
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.
That removal was easier than it should have been, and the reason is a property I designed in without meaning to. Every shipped state is authored to rest at a neutral boundary at its own cycle's edge, so when reasoning ends the animated star just unmounts and the static resting sparkle takes its place underneath it. There was no exit animation to design, because nothing was ever left mid-motion to exit from. A state that stops cleanly does not need to be told how to stop.
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 it went to 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 · A hundred and seventeen tools, three stages
Leo runs a lot of tools. The star cannot report them and should not try, so it shows a coarse stage while the label underneath it names the exact tool. Two resolutions at once, on purpose.
The reducing is a list: one hundred and seventeen recognised tool names resolve to the three forward stages above, and membership is explicit rather than inferred. The star only moves forward, and never restarts when one tool swaps for another, which is what stopped it flickering through a turn that plans, queries, then plans again.
117 recognised tool names, resolved in order. First match wins.
- buildingthe name is in the build setcomposing something a person is meant to look at13 of 117
- queryingany other tool is runningfetching, reading, searching
- thinkingno tool, or a name the map does not knowthe default, and deliberately the quiet one
A tool counts as building only if it's named in that set, and anything else running reads as querying. Thinking is the default: whatever name the map doesn't recognise falls there instead of being guessed at.
The stage I got wrong was building. It meant building a chart, and its membership was four chart tools. Then I pulled the traces from five real chart attempts and it had not fired once, because what those turns spent their time on was fetching and reading.
There was an easy fix available, which was to add the query tools to the building set. That would have put the animation on screen and it would have been a lie, the star claiming Leo was composing something while it was still pulling rows. So the stage widened instead, from building a chart to composing anything a person is meant to look at, and its membership went from four names to thirteen. Data access stayed in querying with a test pinning it there.
06 · 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
Most of the work was removal, and 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.
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.

