Web InnoventixFreeCode

List Alert

Original · free

An alert containing a short bullet list of items.

byWeb InnoventixReact + Tailwind
alertlistalerts
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-list.json
alert-list.tsx
"use client";

import { useState } from "react";

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

type AlertItem = {
  id: string;
  variant: Variant;
  title: string;
  items: string[];
};

type VariantStyle = {
  wrap: string;
  iconWrap: string;
  title: string;
  body: string;
  marker: string;
  ring: string;
  icon: JSX.Element;
};

const VARIANTS: Record<Variant, VariantStyle> = {
  info: {
    wrap: "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40",
    iconWrap: "bg-sky-100 text-sky-600 dark:bg-sky-900/60 dark:text-sky-300",
    title: "text-sky-900 dark:text-sky-100",
    body: "text-sky-800/90 dark:text-sky-200/80",
    marker: "text-sky-500 dark:text-sky-400",
    ring: "focus-visible:ring-sky-500",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9h.01M11 12h1v4h1m8-4a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
      />
    ),
  },
  success: {
    wrap: "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/40",
    iconWrap: "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/60 dark:text-emerald-300",
    title: "text-emerald-900 dark:text-emerald-100",
    body: "text-emerald-800/90 dark:text-emerald-200/80",
    marker: "text-emerald-500 dark:text-emerald-400",
    ring: "focus-visible:ring-emerald-500",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="m9 12.75 2 2 4-4.5m6 1.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
      />
    ),
  },
  warning: {
    wrap: "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/40",
    iconWrap: "bg-amber-100 text-amber-600 dark:bg-amber-900/60 dark:text-amber-300",
    title: "text-amber-900 dark:text-amber-100",
    body: "text-amber-800/90 dark:text-amber-200/80",
    marker: "text-amber-500 dark:text-amber-400",
    ring: "focus-visible:ring-amber-500",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9v3.75m0 3.75h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"
      />
    ),
  },
  danger: {
    wrap: "border-rose-200 bg-rose-50 dark:border-rose-900/60 dark:bg-rose-950/40",
    iconWrap: "bg-rose-100 text-rose-600 dark:bg-rose-900/60 dark:text-rose-300",
    title: "text-rose-900 dark:text-rose-100",
    body: "text-rose-800/90 dark:text-rose-200/80",
    marker: "text-rose-500 dark:text-rose-400",
    ring: "focus-visible:ring-rose-500",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.01"
      />
    ),
  },
};

const INITIAL_ALERTS: AlertItem[] = [
  {
    id: "deploy",
    variant: "success",
    title: "Deployment finished",
    items: [
      "Build passed in 42s across 3 regions.",
      "Database migration 0047 applied cleanly.",
      "Cache purged for 12,480 edge nodes.",
    ],
  },
  {
    id: "review",
    variant: "warning",
    title: "Before you publish this page",
    items: [
      "Meta description is 41 characters over the limit.",
      "Two images are missing descriptive alt text.",
      "The canonical URL points to a staging domain.",
    ],
  },
  {
    id: "billing",
    variant: "danger",
    title: "Payment could not be processed",
    items: [
      "The card ending in 4218 was declined by the issuer.",
      "Your plan renews in 3 days on July 17, 2026.",
      "Automations will pause if payment is not updated.",
    ],
  },
  {
    id: "tips",
    variant: "info",
    title: "New in this release",
    items: [
      "Draft autosave now runs every 5 seconds.",
      "Keyboard shortcut ⌘K opens the command palette.",
      "Exports support CSV, JSON, and Parquet.",
    ],
  },
];

export default function AlertList() {
  const [alerts, setAlerts] = useState<AlertItem[]>(INITIAL_ALERTS);
  const [leaving, setLeaving] = useState<string[]>([]);

  const dismiss = (id: string) => {
    setLeaving((prev) => (prev.includes(id) ? prev : [...prev, id]));
  };

  const finalize = (id: string) => {
    setAlerts((prev) => prev.filter((a) => a.id !== id));
    setLeaving((prev) => prev.filter((x) => x !== id));
  };

  const reset = () => {
    setLeaving([]);
    setAlerts(INITIAL_ALERTS);
  };

  return (
    <section className="relative w-full bg-white px-4 py-16 dark:bg-neutral-950 sm:px-6 sm:py-24">
      <style>{`
        @keyframes alertlist-in {
          from { opacity: 0; transform: translateY(10px) scale(0.985); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes alertlist-out {
          from { opacity: 1; transform: translateX(0); max-height: 40rem; }
          to { opacity: 0; transform: translateX(28px); max-height: 0; margin-bottom: -1rem; }
        }
        .alertlist-enter { animation: alertlist-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .alertlist-leave { animation: alertlist-out 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; overflow: hidden; }
        @media (prefers-reduced-motion: reduce) {
          .alertlist-enter, .alertlist-leave { animation-duration: 0.01ms !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-8 flex items-end justify-between gap-4">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-50">
              Notifications
            </h2>
            <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
              {alerts.length > 0
                ? `${alerts.length} item${alerts.length > 1 ? "s" : ""} need your attention`
                : "You're all caught up."}
            </p>
          </div>
          {alerts.length < INITIAL_ALERTS.length && (
            <button
              type="button"
              onClick={reset}
              className="shrink-0 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
            >
              Restore all
            </button>
          )}
        </div>

        <div className="flex flex-col gap-4">
          {alerts.map((alert) => {
            const v = VARIANTS[alert.variant];
            const isLeaving = leaving.includes(alert.id);
            const titleId = `alertlist-${alert.id}-title`;
            return (
              <div
                key={alert.id}
                role="alert"
                aria-labelledby={titleId}
                aria-hidden={isLeaving}
                onAnimationEnd={(e) => {
                  if (isLeaving && e.animationName === "alertlist-out") {
                    finalize(alert.id);
                  }
                }}
                className={`${isLeaving ? "alertlist-leave" : "alertlist-enter"} rounded-2xl border p-5 shadow-sm ${v.wrap}`}
              >
                <div className="flex items-start gap-4">
                  <span
                    className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${v.iconWrap}`}
                    aria-hidden="true"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.75}
                      className="h-5 w-5"
                    >
                      {v.icon}
                    </svg>
                  </span>

                  <div className="min-w-0 flex-1">
                    <h3
                      id={titleId}
                      className={`text-sm font-semibold ${v.title}`}
                    >
                      {alert.title}
                    </h3>
                    <ul className={`mt-2 space-y-1.5 text-sm ${v.body}`}>
                      {alert.items.map((item, i) => (
                        <li key={i} className="flex gap-2.5">
                          <svg
                            viewBox="0 0 16 16"
                            fill="currentColor"
                            className={`mt-[7px] h-1.5 w-1.5 shrink-0 ${v.marker}`}
                            aria-hidden="true"
                          >
                            <circle cx="8" cy="8" r="8" />
                          </svg>
                          <span className="leading-relaxed">{item}</span>
                        </li>
                      ))}
                    </ul>
                  </div>

                  <button
                    type="button"
                    onClick={() => dismiss(alert.id)}
                    aria-label={`Dismiss: ${alert.title}`}
                    className={`-mr-1 -mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-black/5 hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent dark:text-neutral-500 dark:hover:bg-white/10 dark:hover:text-neutral-200 ${v.ring}`}
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2}
                      className="h-4 w-4"
                    >
                      <path
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        d="M6 6l12 12M18 6L6 18"
                      />
                    </svg>
                  </button>
                </div>
              </div>
            );
          })}

          {alerts.length === 0 && (
            <div className="alertlist-enter rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-12 text-center dark:border-neutral-800 dark:bg-neutral-900/40">
              <p className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
                Inbox zero.
              </p>
              <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
                Every alert has been cleared.
              </p>
              <button
                type="button"
                onClick={reset}
                className="mt-5 rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
              >
                Bring them back
              </button>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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 →