Resolving alert banner for React and Tailwind CSS (shadcn/ui) whose action button runs the fix in place: the button becomes a busy pill, the banner fills from the left while the promise runs, the icon and tone morph to success, and the alert folds away on its own. It is built to turn warnings into finished actions without a modal, so people retry a payment, resend a verification email, or resync data right where the problem is shown. A resolving alert is a status callout that owns the lifecycle of its own remediation; this one takes a Promise-returning onAction and moves through idle, pending, success and error states, shows error copy with a shake and a retry button when the promise rejects, supports info, warning, destructive and success tones mapped to shadcn chart and destructive tokens, optional controlled status, custom success and error titles, a dismiss button, collapse timing, onCollapse and onDismiss callbacks, role alert or status for assistive tech, and a reduced motion fallback. Use it for billing, auth, sync and permission prompts in dashboards and settings pages. The included demo runs three sample fixes and makes no requests. Requires React and lucide-react, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Resolving Alert Banner to your project and fits it to your theme.
Code
How Resolving Alert Banner is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { CreditCard, RotateCcw } from "lucide-react";
import { ResolvingAlertBanner } from "@/components/ui/resolving-alert-banner";
type DemoProps = { autoplay?: boolean; bare?: boolean; failFirst?: boolean; speed?: number };
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
export default function ResolvingAlertBannerDemo({ autoplay = true, bare = false, failFirst = true, speed = 1 }: DemoProps) {
const [run, setRun] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState([] as { id: number; text: string }[]);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const syncAttempts = React.useRef(0);
const paymentRef = React.useRef(null as HTMLDivElement | null);
const emailRef = React.useRef(null as HTMLDivElement | null);
const note = (text: string) => setLog((previous) => [{ id: Date.now() + Math.random(), text }, ...previous].slice(0, 4));
const ms = (value: number) => Math.round(value * speed);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready || !autoplay || motion.reduced || paused) return;
const timers: number[] = [];
const later = (at: number, callback: () => void) => timers.push(window.setTimeout(callback, ms(at)));
later(1400, () => (paymentRef.current?.querySelector("[data-action]") as HTMLButtonElement | null)?.click());
later(4600, () => (emailRef.current?.querySelector("[data-action]") as HTMLButtonElement | null)?.click());
later(10500, () => { syncAttempts.current = 0; setLog([]); setRun((previous) => previous + 1); });
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [motion.ready, motion.reduced, autoplay, paused, run, speed]);
const takeOver = () => autoplay && setPaused(true);
return (
<main className="flex min-h-svh w-full flex-col items-center justify-center gap-5 bg-background px-5 py-6 text-foreground" onPointerDownCapture={takeOver} onKeyDownCapture={takeOver}>
{!bare && (
<header className="w-full max-w-[480px]">
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.2em] text-muted-foreground">Alerts that finish the job</p>
<h1 className="text-[24px] font-semibold leading-tight tracking-tight">Fix it right where it's shown.</h1>
</header>
)}
<section key={run} data-stack className="w-full max-w-[480px] space-y-3">
<ResolvingAlertBanner
ref={paymentRef}
tone="warning"
icon={<CreditCard className="size-5" aria-hidden="true" />}
title="Payment failed"
description="Your card ending in 4242 was declined."
successTitle="Payment succeeded"
successDescription="Receipt sent to billing@acme.com."
expectedMs={ms(2200)}
collapseOnSuccessMs={ms(1500)}
action={{ label: "Retry payment", pendingLabel: "Charging…", doneLabel: "Paid", onAction: async () => { note("Payment retried"); await wait(ms(2200)); note("Payment succeeded"); } }}
onCollapse={() => note("Payment alert dismissed itself")}
/>
<ResolvingAlertBanner
ref={emailRef}
tone="info"
title="Verify your email"
description="We need to confirm maria@acme.com before you can invite teammates."
successTitle="Verification email sent"
successDescription="Check your inbox for the link."
expectedMs={ms(1600)}
collapseOnSuccessMs={ms(1500)}
action={{ label: "Resend email", pendingLabel: "Sending…", doneLabel: "Sent", onAction: async () => { note("Verification email requested"); await wait(ms(1600)); } }}
onCollapse={() => note("Email alert dismissed itself")}
/>
<ResolvingAlertBanner
tone="destructive"
title="Sync failed"
description="3 changes could not reach the server."
errorTitle="Sync failed again"
errorDescription="The server is still unreachable. Try once more."
successTitle="Everything is in sync"
successDescription="3 changes uploaded."
expectedMs={ms(1400)}
collapseOnSuccessMs={ms(1500)}
dismissible
onDismiss={() => note("Sync alert dismissed")}
action={{ label: "Retry sync", pendingLabel: "Syncing…", doneLabel: "Synced", retryLabel: "Retry sync", onAction: async () => {
syncAttempts.current += 1;
note(`Sync attempt ${syncAttempts.current}`);
await wait(ms(1200));
if (failFirst && syncAttempts.current === 1) throw new Error("offline");
note("Sync succeeded");
} }}
onCollapse={() => note("Sync alert dismissed itself")}
/>
</section>
<ul data-log aria-live="polite" className="w-full max-w-[480px] min-h-10 space-y-0.5 text-[11px] text-muted-foreground">
{log.length === 0 ? <li className="text-muted-foreground/70">Sample alerts · nothing is really charged or sent</li> : log.map((entry) => <li key={entry.id} data-log-entry className="truncate">{entry.text}</li>)}
</ul>
{!bare && (
<footer className="flex w-full max-w-[480px] items-center justify-between text-[10px] text-muted-foreground">
<span>Click an action to run it in place</span>
<button type="button" data-replay onClick={() => { syncAttempts.current = 0; setLog([]); setPaused(false); setRun((previous) => previous + 1); }} className="inline-flex items-center gap-1 rounded px-1 underline decoration-border underline-offset-4 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><RotateCcw className="size-3" aria-hidden="true" />Replay</button>
</footer>
)}
</main>
);
}








