Every engineer who has shipped an upload page has shipped this bar. Eight lines. A <div> for the track, a second <div> inside with width: ${value}% and a transition: width 0.3s ease-out. The reviewer opens the demo video: 0, 20, 40, 60, 80, 100. The bar fills. The reviewer nods. Merge. Ship.
A week later three tickets land. A user uploading a 4GB dataset reports “the bar jumped backwards.” A second reports “the bar slams to zero when I retry a chunk.” A third asks “does the upload page even have a progress bar? My screen reader is completely silent on it.” Same eight lines. Three user groups. Zero code changes.
jump forward feels like landing. drop backward feels like a glitch. same 0.3s ease-out curve — reverse-played, it's ease-in.
The bar above is running the naive version. Drag the scrubber up and down — the bar follows. Try dragging it from 80 back to 40: the bar sweeps smoothly forward, then SNAPS backward on the retreat. That's ease-out playing in reverse; forward it reads as “landing”, backward it reads as “glitch”.
// The 8-line version every junior ships first.// Works when value only GROWS. Breaks the moment the signal reverses:// - File retry returns from 80% to 0% → bar slams backwards// - Batch recompute drops rolling average → looks like a glitch// - Screen reader hears nothing — no role, no valuenow, no labelfunction NaiveBar({ value }) { return ( <div className="track"> <div className="fill" style={{ width: `${value}%`, transition: 'width 0.3s ease-out', }} /> </div> );}The eight lines look harmless. Three probes below test whether they actually serve the user. Predict the outcome before each reveal — the probe answers are how the felt wall lands.
You have a bar with transition: width 0.3s ease-out. The current value is 80. The server returns 60 after a retry handshake. The bar needs to settle at 60. Which motion does the user actually see?
With transition: width 0.3s ease-out and the value dropping from 80 to 60, how does the bar look?