A dark assistant panel that slides in from the right over a light invoicing app. It shows the question, three thinking steps whose current label shimmers, and a skeleton card with a moving sheen. When the steps finish, a result card springs in with a total and animated bars, and the matching rows light up in the table behind it. The demo closes and replays until someone clicks. On phones the sidebar and extra columns hide and the panel takes most of the frame.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowUp, Check, FileText, Home, MessageSquareText, Settings, Users, Wallet, X } from "lucide-react";
const invoices = [
{ client: "Brightwater Dental", number: "INV-1048", due: "Oct 2", amount: 3200, status: "Overdue", days: 7 },
{ client: "Okafor and Lund Architects", number: "INV-1047", due: "Oct 14", amount: 5400, status: "Sent", days: 0 },
{ client: "Pine Row Bakery", number: "INV-1045", due: "Sep 26", amount: 1870, status: "Overdue", days: 13 },
{ client: "Marlow Cycle Works", number: "INV-1044", due: "Oct 20", amount: 960, status: "Draft", days: 0 },
{ client: "Tidewell Physio", number: "INV-1041", due: "Sep 18", amount: 3350, status: "Overdue", days: 21 },
{ client: "Halden Print Co", number: "INV-1040", due: "Sep 30", amount: 2100, status: "Paid", days: 0 },
{ client: "Juno Florist", number: "INV-1038", due: "Sep 28", amount: 640, status: "Paid", days: 0 },
];
const steps = ["Reading 42 invoices", "Matching payments received", "Adding up what is overdue"];
const nav = [
{ label: "Home", icon: Home },
{ label: "Invoices", icon: FileText },
{ label: "Clients", icon: Users },
{ label: "Payouts", icon: Wallet },
{ label: "Settings", icon: Settings },
];
const badge: Record<string, string> = { Overdue: "bg-rose-100 text-rose-800", Sent: "bg-sky-100 text-sky-800", Draft: "bg-slate-100 text-slate-700", Paid: "bg-emerald-100 text-emerald-800" };
const money = (n: number) => `$${n.toLocaleString("en-US")}`;
const overdue = invoices.filter((i) => i.status === "Overdue");
const overdueTotal = overdue.reduce((sum, i) => sum + i.amount, 0);
const spring = { type: "spring", stiffness: 280, damping: 30 } as const;
export default function AssistantSidePanelWithThinkingShimmer() {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const inView = useInView(rootRef, { amount: 0.3 });
const [open, setOpen] = useState(false);
const [stage, setStage] = useState(0); // steps finished; steps.length means the answer is ready
const [question, setQuestion] = useState("Which invoices are overdue, and by how much?");
const [draft, setDraft] = useState("");
const [demo, setDemo] = useState(true);
const ready = stage >= steps.length;
// Demo loop: open, think through each step, show the answer, close, repeat.
useEffect(() => {
if (!inView) return;
if (reduce) {
if (demo) (setOpen(true), setStage(steps.length));
return;
}
let timer: ReturnType<typeof setTimeout> | undefined;
if (!open) {
if (demo) timer = setTimeout(() => (setStage(0), setOpen(true)), 250);
} else if (!ready) {
timer = setTimeout(() => setStage((s) => s + 1), stage === 0 ? 1000 : 750);
} else if (demo) {
timer = setTimeout(() => setOpen(false), 5200);
}
return () => clearTimeout(timer);
}, [inView, reduce, open, stage, ready, demo]);
const askAgain = (text: string) => {
if (!text.trim()) return;
setDemo(false);
setQuestion(text.trim());
setDraft("");
setStage(reduce ? steps.length : 0);
};
return (
<section ref={rootRef} className="bg-slate-100 px-4 py-12 text-slate-900 sm:px-6 sm:py-16">
<style>{`
@keyframes assistant-side-panel-text { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@keyframes assistant-side-panel-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.assistant-side-panel-text {
background: linear-gradient(90deg, #a5a3c9 0%, #a5a3c9 38%, #ffffff 50%, #a5a3c9 62%, #a5a3c9 100%) 0 0 / 200% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent;
animation: assistant-side-panel-text 1.5s linear infinite;
}
.assistant-side-panel-bone { position: relative; overflow: hidden; background: rgba(255,255,255,.07); }
.assistant-side-panel-bone::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(196,181,253,.28), transparent);
animation: assistant-side-panel-sweep 1.3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.assistant-side-panel-text { animation: none; color: #e2e8f0; background: none; }
.assistant-side-panel-bone::after { animation: none; }
}
`}</style>
<div className="relative mx-auto h-[640px] max-w-5xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5" onPointerDown={() => setDemo(false)}>
{/* The app underneath */}
<div className="flex h-full">
<nav aria-label="App sections" className="hidden w-48 flex-none border-r border-slate-100 bg-slate-50 p-4 md:block">
<p className="px-2 text-sm font-semibold tracking-tight">Ledgerline</p>
<ul className="mt-6 space-y-1 text-sm">
{nav.map((item) => (
<li key={item.label}>
<a href="#" aria-current={item.label === "Invoices" ? "page" : undefined} className={`flex h-10 items-center gap-2.5 rounded-lg px-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 ${item.label === "Invoices" ? "bg-white font-medium text-slate-900 shadow-sm" : "text-slate-600 hover:text-slate-900"}`}>
<item.icon aria-hidden="true" className="h-4 w-4" />
{item.label}
</a>
</li>
))}
</ul>
</nav>
<div className="min-w-0 flex-1 p-4 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold tracking-tight">Invoices</h2>
<p className="text-sm text-slate-600">October, 42 sent</p>
</div>
<button type="button" aria-expanded={open} aria-controls="assistant-side-panel" onClick={() => (setDemo(false), setStage(reduce ? steps.length : 0), setOpen(!open))} className="inline-flex h-10 items-center gap-2 rounded-full bg-violet-600 px-4 text-sm font-semibold text-white transition hover:bg-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2">
<MessageSquareText aria-hidden="true" className="h-4 w-4" />
Ask assistant
</button>
</div>
<dl className="mt-5 grid grid-cols-3 gap-3">
{[
{ label: "Outstanding", value: "$14,780" },
{ label: "Overdue", value: money(overdueTotal) },
{ label: "Paid this month", value: "$31,240" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-slate-100 p-3">
<dt className="truncate text-xs text-slate-600">{stat.label}</dt>
<dd className="mt-1 text-base font-semibold tabular-nums sm:text-lg">{stat.value}</dd>
</div>
))}
</dl>
<table className="mt-5 w-full text-left text-sm">
<thead className="text-xs text-slate-600">
<tr className="border-b border-slate-100">
<th scope="col" className="py-2 pl-3 font-medium">Client</th>
<th scope="col" className="hidden py-2 font-medium lg:table-cell">Invoice</th>
<th scope="col" className="hidden py-2 font-medium sm:table-cell">Due</th>
<th scope="col" className="py-2 text-right font-medium">Amount</th>
<th scope="col" className="py-2 pr-3 text-right font-medium">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice, i) => {
const lit = open && ready && invoice.status === "Overdue";
return (
<tr key={invoice.number} className={`border-b border-slate-100 transition-colors duration-500 ${lit ? "bg-violet-50" : ""}`} style={{ transitionDelay: lit ? `${i * 60}ms` : "0ms" }}>
<td className={`max-w-[9rem] truncate py-3 pl-3 font-medium transition-shadow duration-500 sm:max-w-none ${lit ? "shadow-[inset_3px_0_0_0_#7c3aed]" : ""}`}>{invoice.client}</td>
<td className="hidden py-3 text-slate-600 lg:table-cell">{invoice.number}</td>
<td className="hidden py-3 text-slate-600 sm:table-cell">{invoice.due}</td>
<td className="py-3 text-right tabular-nums">{money(invoice.amount)}</td>
<td className="py-3 pr-3 text-right">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${badge[invoice.status]}`}>{invoice.status}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Scrim */}
<motion.button type="button" tabIndex={open ? 0 : -1} aria-label="Close the assistant" onClick={() => setOpen(false)} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={{ duration: 0.3 }} className={`absolute inset-0 cursor-default bg-slate-900/15 ${open ? "" : "pointer-events-none"}`} />
{/* The panel */}
<motion.aside id="assistant-side-panel" aria-label="Assistant" aria-hidden={!open} initial={false} animate={{ x: open ? "0%" : "104%" }} transition={reduce ? { duration: 0 } : spring} className="absolute inset-y-2 right-2 flex w-[calc(100%-3rem)] max-w-[390px] flex-col overflow-hidden rounded-2xl bg-slate-950 text-slate-100 shadow-2xl shadow-violet-950/50 ring-1 ring-white/10">
<div aria-hidden="true" className="pointer-events-none absolute -right-20 -top-24 h-56 w-56 rounded-full bg-violet-600/40 blur-3xl" />
<div className="relative flex items-center gap-3 px-4 py-3.5">
<span aria-hidden="true" className="grid h-8 w-8 place-items-center rounded-full bg-gradient-to-br from-violet-400 to-indigo-600">
<span className="h-3 w-3 rounded-full border-2 border-white" />
</span>
<p className="flex-1 text-sm font-semibold">Assistant</p>
<button type="button" tabIndex={open ? 0 : -1} onClick={() => (setDemo(false), setOpen(false))} aria-label="Close" className="grid h-10 w-10 place-items-center rounded-full text-slate-400 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400">
<X aria-hidden="true" className="h-4 w-4" />
</button>
</div>
<div className="relative flex-1 space-y-4 overflow-y-auto px-4 pb-4" aria-live="polite">
<p className="ml-auto w-fit max-w-[90%] rounded-2xl rounded-br-md bg-white/10 px-3.5 py-2.5 text-sm leading-relaxed">{question}</p>
{ready ? (
<p className="flex items-center gap-2.5 text-sm text-slate-400">
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-violet-500 text-white">
<Check aria-hidden="true" className="h-3 w-3" strokeWidth={3.5} />
</span>
Checked 42 invoices against 38 payments
</p>
) : (
<ol className="space-y-2.5 text-sm">
{steps.map((step, i) => {
const done = stage > i;
const current = stage === i;
return (
<motion.li key={step} initial={false} animate={{ opacity: done || current ? 1 : 0.35 }} className="flex items-center gap-2.5">
<span className={`grid h-5 w-5 flex-none place-items-center rounded-full transition-colors duration-300 ${done ? "bg-violet-500 text-white" : "border border-white/20"}`}>
{done ? <Check aria-hidden="true" className="h-3 w-3" strokeWidth={3.5} /> : current ? <span className="h-2 w-2 animate-pulse rounded-full bg-violet-300 motion-reduce:animate-none" /> : null}
</span>
<span className={current ? "assistant-side-panel-text font-medium" : done ? "text-slate-400" : "text-slate-400"}>{step}</span>
</motion.li>
);
})}
</ol>
)}
<AnimatePresence mode="wait" initial={false}>
{!ready ? (
<motion.div key="bones" exit={{ opacity: 0, scale: 0.97 }} transition={{ duration: 0.18 }} className="space-y-3 rounded-2xl border border-white/10 p-4" role="status" aria-label="Working on the answer">
<div className="assistant-side-panel-bone h-3 w-1/3 rounded-full" />
<div className="assistant-side-panel-bone h-8 w-2/3 rounded-lg" />
<div className="assistant-side-panel-bone h-3 w-full rounded-full" />
<div className="assistant-side-panel-bone h-3 w-5/6 rounded-full" />
<div className="assistant-side-panel-bone h-3 w-3/5 rounded-full" />
</motion.div>
) : (
<motion.div key="answer" initial={reduce ? false : { opacity: 0, y: 18, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 340, damping: 26 }} className="rounded-2xl bg-gradient-to-b from-violet-400/60 to-white/10 p-px">
<div className="rounded-2xl bg-slate-900 p-4">
<p className="text-xs font-medium text-violet-300">{overdue.length} invoices overdue</p>
<p className="mt-1 text-3xl font-semibold tracking-tight tabular-nums text-white">{money(overdueTotal)}</p>
<ul className="mt-4 space-y-3">
{overdue.map((invoice, i) => (
<li key={invoice.number}>
<p className="flex items-baseline justify-between gap-3 text-sm">
<span className="truncate">{invoice.client}</span>
<span className="flex-none tabular-nums text-slate-300">{money(invoice.amount)}</span>
</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<motion.div initial={reduce ? false : { scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ duration: 0.7, delay: 0.2 + i * 0.1, ease: [0.22, 1, 0.36, 1] }} className="h-full origin-left rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-400" style={{ width: `${(invoice.amount / 3350) * 100}%` }} />
</div>
<span className="flex-none whitespace-nowrap text-right text-xs text-slate-400">{invoice.days} days late</span>
</div>
</li>
))}
</ul>
<div className="mt-4 flex gap-2">
<button type="button" tabIndex={open ? 0 : -1} className="h-10 flex-1 rounded-full bg-violet-500 text-sm font-semibold text-white transition hover:bg-violet-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">Send reminders</button>
<button type="button" tabIndex={open ? 0 : -1} className="h-10 flex-1 rounded-full bg-white/10 text-sm font-semibold text-white transition hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">Export list</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
askAgain(draft);
}}
className="relative m-3 mt-0 flex items-center gap-2 rounded-full bg-white/10 py-1 pl-4 pr-1 focus-within:ring-2 focus-within:ring-violet-400"
>
<label htmlFor="assistant-side-panel-input" className="sr-only">
Ask a follow-up question
</label>
<input id="assistant-side-panel-input" tabIndex={open ? 0 : -1} value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Ask a follow-up..." className="h-10 min-w-0 flex-1 bg-transparent text-sm text-white placeholder:text-slate-400 focus:outline-none" />
<button type="submit" tabIndex={open ? 0 : -1} aria-label="Send question" className="grid h-10 w-10 flex-none place-items-center rounded-full bg-white text-slate-950 transition hover:bg-violet-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400">
<ArrowUp aria-hidden="true" className="h-4 w-4" strokeWidth={2.5} />
</button>
</form>
</motion.aside>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/assistant-side-panel-with-thinking-shimmer.json
The label has a horizontal gradient as its background, twice as wide as the text, with a bright band in the middle. `background-clip: text` with a transparent text colour shows the gradient through the letters, and a keyframe slides the background position so the band travels across.
It is a framer-motion `aside` positioned on the right of a relatively positioned frame. Its `x` animates between `104%` and `0%` on a spring, so it overshoots slightly and settles. Only `transform` is animated.
Nothing here calls a service. Set `stage` from your own progress events, one per step, and render your response in place of the result card once the stage reaches the number of steps.
Each row checks whether the panel is open, the answer is ready and its own status matches. If so it gets a tinted background and an inset left edge, with a small `transitionDelay` per row so the highlights arrive in sequence.
This one is non-modal: the page stays usable and the scrim only closes the panel. If you make it modal, add `role="dialog"`, `aria-modal` and a focus trap, and return focus to the open button on close.
Related: Streaming AI Chat Thread with Typing Indicator, Command Palette with Spring Highlight and Live Filtering, Glowing AI Prompt Box with Morphing Send Button.
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.