A chat panel that shows an AI assistant part way through a task. Under the user's question sits a working block with a spinner made of six dashed rings turning in opposite directions, a status line that changes for each stage and a three line window onto a numbered log. The log slides up one line at a time so the current step stays in the middle, finished steps get a check and the top and bottom fade out. A progress arc around the spinner fills as steps complete, and a pause button in the footer stops everything. The panel fits a 360 pixel screen.
// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/ai-loading.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
const QUESTION = "Compare the three venue quotes and tell me which one fits a 120 person launch in March.";
// The assistant works through these stages in order. Each line is one step in the log.
const stages = [
{
status: "Reading the quotes",
lines: ["Opening Harbour Loft quote (4 pages)", "Opening The Glasshouse quote (2 pages)", "Opening Foundry Hall quote (6 pages)", "Pulling out room hire, catering and staffing", "Noting the deposit terms"],
},
{
status: "Checking the numbers",
lines: ["Converting every quote to a per guest price", "Adding service charge where it was left out", "Flagging overtime after 23:00", "Comparing standing capacity with 120 guests", "Checking March dates still open"],
},
{
status: "Writing the summary",
lines: ["Ranking the venues on total cost", "Listing what each quote leaves out", "Drafting three questions to send back", "Checking the totals once more"],
},
];
const LINE_HEIGHT = 28;
const STEP_MS = 850;
const RING_COLOURS = ["#f43f5e", "#06b6d4", "#22c55e", "#f59e0b", "#eab308", "#ec4899"];
/** Six dashed rings turning in opposite directions, with a progress arc around them. */
function Spinner({ progress }: { progress: number }) {
const length = 2 * Math.PI * 112;
return (
<svg viewBox="0 0 240 240" className="h-7 w-7 shrink-0" role="img" aria-label={`${Math.round(progress * 100)} percent done`}>
<g className="ai-agent-working-rings" strokeWidth="14" fill="none" strokeDasharray="18% 40%" strokeLinecap="round">
{RING_COLOURS.map((colour, i) => (
<circle key={colour} cx="120" cy="120" r={92 - i * 15} stroke={colour} />
))}
</g>
<circle cx="120" cy="120" r="112" fill="none" stroke="#e2e8f0" strokeWidth="10" />
<circle
cx="120"
cy="120"
r="112"
fill="none"
stroke="#0f172a"
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={length}
strokeDashoffset={length * (1 - progress)}
transform="rotate(-90 120 120)"
className="transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none"
/>
</svg>
);
}
export default function AiAgentWorkingStatusWithScrollingStepLog() {
const rootRef = useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { margin: "100px" });
const reduce = useReducedMotion();
const [stageIndex, setStageIndex] = useState(0);
const [step, setStep] = useState(reduce ? 2 : 0);
const [running, setRunning] = useState(true);
const stage = stages[stageIndex];
const totalSteps = stages.reduce((n, s) => n + s.lines.length, 0);
const doneSteps = stages.slice(0, stageIndex).reduce((n, s) => n + s.lines.length, 0) + step;
// Move to the next line on a timer, then to the next stage, then start again. Only while visible.
useEffect(() => {
if (!running || !inView || reduce) return;
const timer = setTimeout(() => {
if (step < stage.lines.length - 1) setStep(step + 1);
else {
setStageIndex((stageIndex + 1) % stages.length);
setStep(0);
}
}, STEP_MS);
return () => clearTimeout(timer);
}, [running, inView, reduce, step, stageIndex, stage.lines.length]);
return (
<div ref={rootRef} className="flex w-full items-center justify-center bg-slate-100 px-4 py-14">
<style>{`
@keyframes ai-agent-working-cw { to { transform: rotate(360deg); } }
@keyframes ai-agent-working-ccw { to { transform: rotate(-360deg); } }
.ai-agent-working-rings circle { transform-origin: 120px 120px; animation: ai-agent-working-cw 5s linear infinite; }
.ai-agent-working-rings circle:nth-child(2n) { animation-name: ai-agent-working-ccw; animation-duration: 6.5s; }
.ai-agent-working-rings circle:nth-child(3n) { animation-duration: 8s; }
.ai-agent-working-paused circle { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .ai-agent-working-rings circle { animation: none; } }
`}</style>
<section className="w-full max-w-lg overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-300/40" aria-label="AI assistant conversation">
<header className="flex items-center gap-3 border-b border-slate-200 px-5 py-4">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white" aria-hidden="true">
Ev
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-slate-900">Events assistant</h2>
<p className="flex items-center gap-1.5 text-xs text-slate-600">
<span className={`h-1.5 w-1.5 rounded-full ${running ? "bg-emerald-500" : "bg-amber-500"}`} aria-hidden="true" />
{running ? "Working on your request" : "Paused"}
</p>
</div>
</header>
<div className="space-y-5 px-5 py-6">
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-slate-900 px-4 py-3 text-sm leading-relaxed text-white">{QUESTION}</p>
<div className={`rounded-2xl rounded-bl-md border border-slate-200 bg-slate-50 p-4 ${running ? "" : "ai-agent-working-paused"}`}>
<div className="flex items-center gap-3">
<Spinner progress={doneSteps / totalSteps} />
<div className="relative h-5 min-w-0 flex-1 overflow-hidden" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={stage.status}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -10 }}
transition={{ duration: 0.25 }}
className="truncate text-sm font-semibold text-slate-900"
>
{stage.status}...
</motion.p>
</AnimatePresence>
</div>
<span className="shrink-0 font-mono text-xs text-slate-600">
Stage {stageIndex + 1}/{stages.length}
</span>
</div>
{/* A three line window. The list slides up one line per step, so the current step stays in the middle. */}
<div className="relative mt-3 overflow-hidden rounded-lg" style={{ height: LINE_HEIGHT * 3 }}>
<motion.ol
key={stageIndex}
className="font-mono text-xs"
initial={false}
animate={{ y: (1 - step) * LINE_HEIGHT }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 24 }}
>
{stage.lines.map((line, i) => (
<li key={line} className={`flex items-center gap-2 px-2 transition-colors duration-300 ${i === step ? "text-slate-900" : "text-slate-500"}`} style={{ height: LINE_HEIGHT }}>
<span className="w-5 shrink-0 select-none text-right text-slate-400">{i + 1}</span>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center" aria-hidden="true">
{i < step ? (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5 text-emerald-600" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5l3.2 3.2L13 5" />
</svg>
) : (
<span className={`h-1.5 w-1.5 rounded-full ${i === step ? "bg-slate-900 motion-safe:animate-pulse" : "bg-slate-300"}`} />
)}
</span>
<span className="truncate">{line}</span>
</li>
))}
</motion.ol>
<div className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-slate-50 to-transparent" aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-slate-50 to-transparent" aria-hidden="true" />
</div>
</div>
</div>
<footer className="flex items-center justify-between gap-3 border-t border-slate-200 px-5 py-3">
<p className="text-xs text-slate-600">
Step {doneSteps + 1} of {totalSteps}
</p>
<button
type="button"
aria-pressed={!running}
onClick={() => setRunning(!running)}
className="inline-flex h-10 items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 text-sm font-medium text-slate-900 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
{running ? <path d="M4 3h3v10H4zM9 3h3v10H9z" /> : <path d="M4 2.5v11l9-5.5z" />}
</svg>
{running ? "Pause" : "Resume"}
</button>
</footer>
</section>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/ai-agent-working-status-with-scrolling-step-log.json
People wait longer and trust the result more when they can see what is being done. A step log also makes it clear when a task is stuck, because the line stops changing.
The whole list is moved with a spring driven `transform` inside a fixed height box that hides overflow. It is offset by one row so the active line sits in the middle, with the previous line above and the next below.
Replace the timer effect with your own events. Keep `stageIndex` and `step` in state and update them when your stream sends a tool call or a status message. Everything else reads from those two values.
It is one SVG with six circles that share a dash pattern. A style block spins odd and even circles in opposite directions at different speeds, which gives the shifting pattern. The outer arc is a seventh circle whose dash offset follows the progress.
Only in the demo. After the last stage it returns to the first so the preview keeps moving. In a product, swap the working block for the answer when the task ends.
Adapted from AI Loading State in Kokonut UI, used under its open-source licence. Licences and credits
Related: Streaming AI Chat Thread with Typing Indicator, Assistant Side Panel with Thinking Shimmer, Multi-Step Loading Sequence with Drawn Checkmarks.
More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.