Web InnoventixFreeCode

With Action Alert

Original · free

An alert with title, body and action buttons.

byWeb InnoventixReact + Tailwind
alertwithactionalerts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/alert-with-action.json
alert-with-action.tsx
"use client";

import { useState, useId, useCallback } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Tone = "info" | "success" | "warning" | "danger";

type AlertItem = {
  id: string;
  tone: Tone;
  title: string;
  body: string;
  primary: string;
  secondary: string;
};

const TONES: Record<
  Tone,
  {
    ring: string;
    bar: string;
    iconWrap: string;
    iconText: string;
    title: string;
    body: string;
    primaryBtn: string;
    secondaryBtn: string;
    dismiss: string;
    surface: string;
  }
> = {
  info: {
    surface: "bg-sky-50/80 dark:bg-sky-950/30",
    ring: "ring-sky-200/70 dark:ring-sky-400/20",
    bar: "bg-sky-500 dark:bg-sky-400",
    iconWrap: "bg-sky-100 dark:bg-sky-500/15",
    iconText: "text-sky-600 dark:text-sky-300",
    title: "text-sky-950 dark:text-sky-100",
    body: "text-sky-800/80 dark:text-sky-200/70",
    primaryBtn:
      "bg-sky-600 text-white hover:bg-sky-500 focus-visible:ring-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400",
    secondaryBtn:
      "text-sky-800 hover:bg-sky-100 focus-visible:ring-sky-500 dark:text-sky-200 dark:hover:bg-sky-500/15",
    dismiss:
      "text-sky-600/70 hover:bg-sky-100 hover:text-sky-800 focus-visible:ring-sky-500 dark:text-sky-300/70 dark:hover:bg-sky-500/15 dark:hover:text-sky-100",
  },
  success: {
    surface: "bg-emerald-50/80 dark:bg-emerald-950/30",
    ring: "ring-emerald-200/70 dark:ring-emerald-400/20",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    iconWrap: "bg-emerald-100 dark:bg-emerald-500/15",
    iconText: "text-emerald-600 dark:text-emerald-300",
    title: "text-emerald-950 dark:text-emerald-100",
    body: "text-emerald-800/80 dark:text-emerald-200/70",
    primaryBtn:
      "bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400",
    secondaryBtn:
      "text-emerald-800 hover:bg-emerald-100 focus-visible:ring-emerald-500 dark:text-emerald-200 dark:hover:bg-emerald-500/15",
    dismiss:
      "text-emerald-600/70 hover:bg-emerald-100 hover:text-emerald-800 focus-visible:ring-emerald-500 dark:text-emerald-300/70 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-100",
  },
  warning: {
    surface: "bg-amber-50/80 dark:bg-amber-950/30",
    ring: "ring-amber-200/70 dark:ring-amber-400/20",
    bar: "bg-amber-500 dark:bg-amber-400",
    iconWrap: "bg-amber-100 dark:bg-amber-500/15",
    iconText: "text-amber-600 dark:text-amber-300",
    title: "text-amber-950 dark:text-amber-100",
    body: "text-amber-800/80 dark:text-amber-200/70",
    primaryBtn:
      "bg-amber-600 text-white hover:bg-amber-500 focus-visible:ring-amber-500 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400",
    secondaryBtn:
      "text-amber-800 hover:bg-amber-100 focus-visible:ring-amber-500 dark:text-amber-200 dark:hover:bg-amber-500/15",
    dismiss:
      "text-amber-600/70 hover:bg-amber-100 hover:text-amber-800 focus-visible:ring-amber-500 dark:text-amber-300/70 dark:hover:bg-amber-500/15 dark:hover:text-amber-100",
  },
  danger: {
    surface: "bg-rose-50/80 dark:bg-rose-950/30",
    ring: "ring-rose-200/70 dark:ring-rose-400/20",
    bar: "bg-rose-500 dark:bg-rose-400",
    iconWrap: "bg-rose-100 dark:bg-rose-500/15",
    iconText: "text-rose-600 dark:text-rose-300",
    title: "text-rose-950 dark:text-rose-100",
    body: "text-rose-800/80 dark:text-rose-200/70",
    primaryBtn:
      "bg-rose-600 text-white hover:bg-rose-500 focus-visible:ring-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400",
    secondaryBtn:
      "text-rose-800 hover:bg-rose-100 focus-visible:ring-rose-500 dark:text-rose-200 dark:hover:bg-rose-500/15",
    dismiss:
      "text-rose-600/70 hover:bg-rose-100 hover:text-rose-800 focus-visible:ring-rose-500 dark:text-rose-300/70 dark:hover:bg-rose-500/15 dark:hover:text-rose-100",
  },
};

function ToneIcon({ tone, className }: { tone: Tone; className?: string }) {
  const common = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 2,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className,
    "aria-hidden": true,
  };
  if (tone === "success") {
    return (
      <svg {...common}>
        <path d="M20 6 9 17l-5-5" />
      </svg>
    );
  }
  if (tone === "warning") {
    return (
      <svg {...common}>
        <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
        <path d="M12 9v4" />
        <path d="M12 17h.01" />
      </svg>
    );
  }
  if (tone === "danger") {
    return (
      <svg {...common}>
        <circle cx="12" cy="12" r="9" />
        <path d="m15 9-6 6" />
        <path d="m9 9 6 6" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <circle cx="12" cy="12" r="9" />
      <path d="M12 16v-4" />
      <path d="M12 8h.01" />
    </svg>
  );
}

const INITIAL: AlertItem[] = [
  {
    id: "storage",
    tone: "warning",
    title: "You're almost out of storage",
    body: "Your workspace is at 94% of its 20 GB limit. New uploads and backups will pause once you hit the cap.",
    primary: "Upgrade plan",
    secondary: "Manage files",
  },
  {
    id: "deploy",
    tone: "success",
    title: "Deployment succeeded",
    body: "Build #2481 shipped to production in 38s. All 214 checks passed and the release is now live.",
    primary: "View release",
    secondary: "Roll back",
  },
  {
    id: "billing",
    tone: "danger",
    title: "Payment failed",
    body: "We couldn't charge the card ending in 4471. Update your billing details before Jul 21 to keep your team's access.",
    primary: "Update payment",
    secondary: "Contact support",
  },
  {
    id: "invite",
    tone: "info",
    title: "New team invitation",
    body: "Priya Nair invited you to the “Growth” workspace as an editor. The invite expires in 7 days.",
    primary: "Accept invite",
    secondary: "Decline",
  },
];

export default function AlertWithAction() {
  const reduce = useReducedMotion();
  const uid = useId().replace(/[:]/g, "");
  const [items, setItems] = useState<AlertItem[]>(INITIAL);
  const [status, setStatus] = useState<string>("");

  const dismiss = useCallback((id: string, title: string) => {
    setItems((prev) => prev.filter((i) => i.id !== id));
    setStatus(`Dismissed alert: ${title}`);
  }, []);

  const act = useCallback((label: string, title: string) => {
    setStatus(`${label} — ${title}`);
  }, []);

  const reset = useCallback(() => {
    setItems(INITIAL);
    setStatus("Alerts restored");
  }, []);

  const prefix = `alertaction-${uid}`;

  return (
    <section className="relative w-full bg-white px-4 py-16 sm:px-6 sm:py-20 dark:bg-neutral-950">
      <style>{`
        @keyframes ${prefix}-in {
          from { opacity: 0; transform: translateY(10px) scale(0.985); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        .${prefix}-item { animation: ${prefix}-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .${prefix}-item { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <header className="mb-8">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Notifications
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight text-neutral-900 sm:text-3xl dark:text-neutral-50">
            Alerts with actions
          </h2>
          <p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
            Contextual banners that pair a clear message with the next step. Each can be acted on or
            dismissed.
          </p>
        </header>

        <div role="region" aria-label="Active alerts" className="flex flex-col gap-4">
          <AnimatePresence initial={false} mode="popLayout">
            {items.map((item) => {
              const t = TONES[item.tone];
              const titleId = `${prefix}-${item.id}-title`;
              const bodyId = `${prefix}-${item.id}-body`;
              return (
                <motion.div
                  key={item.id}
                  layout={!reduce}
                  initial={reduce ? false : { opacity: 0, y: 10, scale: 0.985 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={
                    reduce
                      ? { opacity: 0 }
                      : { opacity: 0, x: 24, scale: 0.97, transition: { duration: 0.2 } }
                  }
                  transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                  role={item.tone === "danger" ? "alert" : "status"}
                  aria-labelledby={titleId}
                  aria-describedby={bodyId}
                  className={`relative overflow-hidden rounded-2xl ring-1 ${t.surface} ${t.ring} shadow-sm backdrop-blur-sm`}
                >
                  <span aria-hidden className={`absolute inset-y-0 left-0 w-1 ${t.bar}`} />
                  <div className="flex gap-3.5 p-4 pl-5 sm:p-5 sm:pl-6">
                    <span
                      className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${t.iconWrap} ${t.iconText}`}
                    >
                      <ToneIcon tone={item.tone} className="h-5 w-5" />
                    </span>

                    <div className="min-w-0 flex-1">
                      <div className="flex items-start justify-between gap-3">
                        <h3 id={titleId} className={`text-sm font-semibold ${t.title}`}>
                          {item.title}
                        </h3>
                        <button
                          type="button"
                          onClick={() => dismiss(item.id, item.title)}
                          aria-label={`Dismiss: ${item.title}`}
                          className={`-mr-1 -mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent ${t.dismiss}`}
                        >
                          <svg
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            className="h-4 w-4"
                            aria-hidden
                          >
                            <path d="M18 6 6 18" />
                            <path d="m6 6 12 12" />
                          </svg>
                        </button>
                      </div>

                      <p id={bodyId} className={`mt-1 text-sm leading-relaxed ${t.body}`}>
                        {item.body}
                      </p>

                      <div className="mt-3.5 flex flex-wrap items-center gap-2">
                        <button
                          type="button"
                          onClick={() => act(item.primary, item.title)}
                          className={`inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-semibold shadow-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent ${t.primaryBtn}`}
                        >
                          {item.primary}
                        </button>
                        <button
                          type="button"
                          onClick={() => act(item.secondary, item.title)}
                          className={`inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent ${t.secondaryBtn}`}
                        >
                          {item.secondary}
                        </button>
                      </div>
                    </div>
                  </div>
                </motion.div>
              );
            })}
          </AnimatePresence>

          {items.length === 0 && (
            <div
              className={`${reduce ? "" : `${prefix}-item`} flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 px-6 py-12 text-center dark:border-neutral-700`}
            >
              <span className="flex h-11 w-11 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300">
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="h-5 w-5"
                  aria-hidden
                >
                  <path d="M20 6 9 17l-5-5" />
                </svg>
              </span>
              <p className="mt-3 text-sm font-medium text-neutral-700 dark:text-neutral-200">
                You&apos;re all caught up
              </p>
              <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
                No alerts need your attention right now.
              </p>
              <button
                type="button"
                onClick={reset}
                className="mt-4 inline-flex items-center rounded-lg bg-neutral-900 px-3.5 py-2 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-neutral-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
              >
                Restore alerts
              </button>
            </div>
          )}
        </div>

        <p aria-live="polite" role="status" className="sr-only">
          {status}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →